My-Weekly-ToDo-List/src/lib/calendar-events.ts

1013 lines
36 KiB
TypeScript

import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomingEvents as getGoogleEvents, initializeOAuth as initializeGoogleOAuth } from './google-calendar';
import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents } from './apple-calendar';
import { getUpcomingEvents as getOutlookEvents, refreshAccessToken as refreshOutlookTokenAPI, createEvent as createOutlookEvent, updateEvent as updateOutlookEvent, deleteEvent as deleteOutlookEvent } from './outlook-calendar';
import { getUserCalendars as getSynologyCalendars, getUpcomingEvents as getSynologyEvents } from './synology-calendar';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export interface CalendarEvent {
id: string;
title: string;
description?: string;
start: {
dateTime?: string;
date?: string;
};
end: {
dateTime?: string;
date?: string;
};
location?: string;
url?: string;
recurrence?: string;
recurringEventId?: string;
isRecurring?: boolean;
source: 'google' | 'apple' | 'outlook' | 'synology';
calendarId: string;
calendarTitle: string;
backgroundColor?: string;
allDay?: boolean;
}
/**
* Convert friendly recurrence name to RRULE string
*/
function toRRule(recurrence?: string): string | null {
switch (recurrence) {
case 'daily': return 'RRULE:FREQ=DAILY';
case 'weekly': return 'RRULE:FREQ=WEEKLY';
case 'biweekly': return 'RRULE:FREQ=WEEKLY;INTERVAL=2';
case 'monthly': return 'RRULE:FREQ=MONTHLY';
case 'yearly': return 'RRULE:FREQ=YEARLY';
default: return null;
}
}
/**
* Convert friendly recurrence name to Outlook Graph recurrence object
*/
function toOutlookRecurrence(recurrence?: string, startDate?: Date): any {
if (!recurrence || recurrence === 'none') return undefined;
const start = startDate || new Date();
const range = {
type: 'noEnd',
startDate: start.toISOString().split('T')[0],
};
switch (recurrence) {
case 'daily':
return { pattern: { type: 'daily', interval: 1 }, range };
case 'weekly':
return { pattern: { type: 'weekly', interval: 1, daysOfWeek: [['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][start.getDay()]] }, range };
case 'biweekly':
return { pattern: { type: 'weekly', interval: 2, daysOfWeek: [['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][start.getDay()]] }, range };
case 'monthly':
return { pattern: { type: 'absoluteMonthly', interval: 1, dayOfMonth: start.getDate() }, range };
case 'yearly':
return { pattern: { type: 'absoluteYearly', interval: 1, dayOfMonth: start.getDate(), month: start.getMonth() + 1 }, range };
default: return undefined;
}
}
// Google Calendar event color mapping (colorId -> hex color)
const GOOGLE_EVENT_COLORS: Record<string, string> = {
'1': '#7986cb', // Lavender
'2': '#33b679', // Sage
'3': '#8e24aa', // Grape
'4': '#e67c73', // Flamingo
'5': '#f6bf26', // Banana
'6': '#f4511e', // Tangerine
'7': '#039be5', // Peacock
'8': '#616161', // Graphite
'9': '#3f51b5', // Blueberry
'10': '#0b8043', // Basil
'11': '#d50000', // Tomato
};
function getGoogleEventColor(colorId: string): string {
return GOOGLE_EVENT_COLORS[colorId] || '#039be5'; // Default to Peacock
}
export interface CalendarConnection {
id: string;
provider: 'google' | 'apple' | 'outlook' | 'synology';
accessToken: string;
refreshToken?: string;
expiresAt?: Date;
calendars?: Array<{
id: string;
title: string;
isPrimary?: boolean;
selected?: boolean;
backgroundColor?: string;
}> | any; // Type it loosely for JSON compatibility
}
/**
* Refresh the Google access token using the refresh token
*/
async function refreshGoogleToken(connection: CalendarConnection): Promise<string | null> {
if (!connection.refreshToken) {
console.log('[CALENDAR] No refresh token available for connection:', connection.id);
return null;
}
try {
const oauth2Client = initializeGoogleOAuth(
process.env.GOOGLE_CLIENT_ID || '',
process.env.GOOGLE_CLIENT_SECRET || '',
process.env.GOOGLE_REDIRECT_URI || ''
);
oauth2Client.setCredentials({
refresh_token: connection.refreshToken
});
console.log('[CALENDAR] Refreshing Google access token...');
const { credentials } = await oauth2Client.refreshAccessToken();
if (credentials.access_token) {
// Update the token in the database
await prisma.calendarConnection.update({
where: { id: connection.id },
data: {
accessToken: credentials.access_token,
expiresAt: credentials.expiry_date ? new Date(credentials.expiry_date) : null
}
});
console.log('[CALENDAR] Token refreshed successfully');
return credentials.access_token;
}
} catch (error) {
console.error('[CALENDAR] Failed to refresh token:', error);
}
return null;
}
/**
* Refresh the Outlook access token using the refresh token
*/
async function refreshOutlookToken(connection: CalendarConnection): Promise<string | null> {
if (!connection.refreshToken) {
console.log('[CALENDAR] No refresh token available for connection:', connection.id);
return null;
}
try {
console.log('[CALENDAR] Refreshing Outlook access token...');
const data = await refreshOutlookTokenAPI(connection.refreshToken);
if (data.access_token) {
// Update the token in the database
const expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + data.expires_in);
await prisma.calendarConnection.update({
where: { id: connection.id },
data: {
accessToken: data.access_token,
refreshToken: data.refresh_token || connection.refreshToken, // Update refresh token if provided
expiresAt
}
});
console.log('[CALENDAR] Outlook token refreshed successfully');
return data.access_token;
}
} catch (error) {
console.error('[CALENDAR] Failed to refresh Outlook token:', error);
}
return null;
}
/**
* Check if the token is expired or about to expire
*/
function isTokenExpired(expiresAt?: Date): boolean {
if (!expiresAt) return true; // Assume expired if no expiry info
// Consider token expired if it expires within the next 5 minutes
const bufferMs = 5 * 60 * 1000;
return new Date().getTime() > (new Date(expiresAt).getTime() - bufferMs);
}
/**
* Fetch calendar events from connected accounts
*/
export const getCalendarEvents = async (
connections: CalendarConnection[],
timeMin: string,
timeMax: string
): Promise<CalendarEvent[]> => {
const allEvents: CalendarEvent[] = [];
console.log('[CALENDAR] Fetching events from', connections.length, 'connections');
console.log('[CALENDAR] Date range:', timeMin, 'to', timeMax);
for (const connection of connections) {
try {
let events: any[] = [];
let accessToken = connection.accessToken;
if (connection.provider === 'google') {
console.log('[CALENDAR] Processing Google connection:', connection.id);
// Check if token is expired and refresh if needed
if (isTokenExpired(connection.expiresAt)) {
console.log('[CALENDAR] Token expired, attempting refresh...');
const newToken = await refreshGoogleToken(connection);
if (newToken) {
accessToken = newToken;
} else {
console.error('[CALENDAR] Failed to refresh token, skipping connection');
continue;
}
}
// Initialize OAuth client
const oauth2Client = initializeGoogleOAuth(
process.env.GOOGLE_CLIENT_ID || '',
process.env.GOOGLE_CLIENT_SECRET || '',
process.env.GOOGLE_REDIRECT_URI || ''
);
// Identify which calendars to fetch events from
let calendarIds: string[] = [];
let calendars: any[] = [];
// Use stored calendars if available and filtered by selection
if (connection.calendars && Array.isArray(connection.calendars)) {
// We have stored preferences - but check if they have backgroundColor
const storedCalendars = connection.calendars as any[];
const hasMissingColors = storedCalendars.some((c: any) => !c.backgroundColor);
if (hasMissingColors) {
// Refresh from API to get colors
console.log('[CALENDAR] Stored calendars missing backgroundColor, refreshing from API...');
const freshCalendars = await getGoogleCalendars(oauth2Client, accessToken);
// Merge fresh data with stored selection preferences
calendars = storedCalendars.map((stored: any) => {
const fresh = freshCalendars.find(f => f.id === stored.id);
return {
...stored,
backgroundColor: fresh?.backgroundColor || stored.backgroundColor,
summary: fresh?.summary || stored.title // Ensure summary is available
};
});
console.log('[CALENDAR] Refreshed calendars with colors:', calendars.map(c => ({ id: c.id, bg: c.backgroundColor })));
} else {
calendars = storedCalendars;
}
calendarIds = calendars
.filter((c: any) => c.selected !== false) // Include unless explicitly false
.map((c: any) => c.id);
console.log('[CALENDAR] Using calendars, selected:', calendarIds.length, 'of', calendars.length);
} else {
// Fallback: fetch all if no stored list (legacy behavior)
console.log('[CALENDAR] No stored calendars, fetching from API...');
calendars = await getGoogleCalendars(oauth2Client, accessToken);
calendarIds = calendars.map(c => c.id);
console.log('[CALENDAR] Fetched', calendars.length, 'calendars from API');
}
if (calendarIds.length === 0) {
console.log('[CALENDAR] No calendars selected, skipping');
continue;
}
// Fetch events for each calendar
console.log('[CALENDAR] Fetching events from', calendarIds.length, 'calendars...');
for (const calendarId of calendarIds) {
try {
console.log('[CALENDAR] Fetching events from calendar:', calendarId);
const calendarEvents = await getGoogleEvents(
oauth2Client,
accessToken,
calendarId,
timeMin,
timeMax
);
console.log('[CALENDAR] Found', calendarEvents.length, 'events in calendar:', calendarId);
// Get calendar data for color fallback
const calendarData = calendars.find(c => c.id === calendarId);
events = events.concat(calendarEvents.map((event: any) => {
const eventColor = event.colorId ? getGoogleEventColor(event.colorId) : calendarData?.backgroundColor;
return {
id: event.id,
title: event.summary || '(No Title)', // Map summary to title
description: event.description,
start: event.start,
end: event.end,
location: event.location,
source: 'google' as const,
calendarId,
calendarTitle: calendarData?.summary || calendarData?.title || 'Google Calendar',
backgroundColor: eventColor
};
}));
} catch (calError) {
console.error(`[CALENDAR] Error fetching events from calendar ${calendarId}:`, calError);
// Continue with other calendars
}
}
} else if (connection.provider === 'apple') {
const [email, appPassword] = connection.accessToken.split(':');
if (!email || !appPassword) {
console.error('[CALENDAR] Invalid Apple credentials format');
continue;
}
// Get user calendars to identify which ones to fetch events from
// We can pass null/dummy client if getUserCalendars just calls validateCredentials which creates its own client
// Actually getUserCalendars in apple-calendar.ts (wrapper around validateCredentials) takes (email, password)
// But here it was imported as getAppleCalendars(appleClient, accessToken) which matched the old signature?
// Let's check apple-calendar.ts signature for getUserCalendars.
// It is: export const getUserCalendars = validateCredentials;
// validateCredentials: (email: string, appSpecificPassword: string)
const calendars = await getAppleCalendars(email, appPassword);
const calendarIds = calendars.map(c => c.id);
// Fetch events for each calendar
for (const calendarId of calendarIds) {
const calendarEvents = await getAppleEvents(
email,
appPassword,
calendarId,
timeMin,
timeMax
);
console.log(`[CALENDAR] Fetched ${calendarEvents.length} events from calendar ${calendarId}`);
events = events.concat(calendarEvents.map((event: any) => {
// Date-only strings (YYYY-MM-DD) indicate all-day events
const isDateOnly = (s: string) => s && !s.includes('T');
const startIsAllDay = isDateOnly(event.startDate);
return {
id: event.id,
title: event.title,
description: event.description,
start: {
dateTime: startIsAllDay ? undefined : event.startDate,
date: startIsAllDay ? event.startDate : undefined,
},
end: {
dateTime: startIsAllDay ? undefined : event.endDate,
date: startIsAllDay ? event.endDate : undefined,
},
location: event.location,
url: event.url,
recurringEventId: event.recurringEventId,
isRecurring: event.isRecurring,
source: 'apple' as const,
calendarId,
calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar',
backgroundColor: calendars.find(c => c.id === calendarId)?.color || '#FF3B30'
};
}));
}
} else if (connection.provider === 'synology') {
const [username, password] = connection.accessToken.split(':');
const serverUrl = connection.refreshToken;
if (!username || !password || !serverUrl) {
console.error('[CALENDAR] Invalid Synology credentials format');
continue;
}
// Use stored calendar selection (same pattern as Outlook/Google)
let calendarIds: string[] = [];
let calendars: any[] = [];
if (connection.calendars && Array.isArray(connection.calendars) && connection.calendars.length > 0) {
calendars = connection.calendars as any[];
calendarIds = calendars
.filter((c: any) => c.selected !== false)
.map((c: any) => c.id);
console.log('[CALENDAR] Synology: using stored calendars, selected:', calendarIds.length, 'of', calendars.length);
} else {
// Fallback: fetch fresh from server if no stored calendars
console.log('[CALENDAR] Synology: no stored calendars, fetching from server...');
const fresh = await getSynologyCalendars(serverUrl, username, password);
calendars = fresh;
calendarIds = fresh.map(c => c.id);
}
if (calendarIds.length === 0) {
console.log('[CALENDAR] Synology: no calendars selected, skipping');
continue;
}
for (const calendarId of calendarIds) {
try {
const calendarEvents = await getSynologyEvents(
serverUrl,
username,
password,
calendarId,
timeMin,
timeMax
);
console.log(`[CALENDAR] Synology: fetched ${calendarEvents.length} events from calendar ${calendarId}`);
const calendarData = calendars.find(c => c.id === calendarId);
events = events.concat(calendarEvents.map((event: any) => {
const isDateOnly = (s: string) => s && !s.includes('T');
const startIsAllDay = isDateOnly(event.startDate);
return {
id: event.id,
title: event.title,
description: event.description,
start: {
dateTime: startIsAllDay ? undefined : event.startDate,
date: startIsAllDay ? event.startDate : undefined,
},
end: {
dateTime: startIsAllDay ? undefined : event.endDate,
date: startIsAllDay ? event.endDate : undefined,
},
location: event.location,
url: event.url,
recurringEventId: event.recurringEventId,
isRecurring: event.isRecurring,
source: 'synology' as const,
calendarId,
calendarTitle: calendarData?.title || 'Synology Calendar',
backgroundColor: calendarData?.color || '#1b85ff'
};
}));
} catch (calError) {
console.error(`[CALENDAR] Synology: error fetching events from calendar ${calendarId}:`, calError);
}
}
} else if (connection.provider === 'outlook') {
console.log('[CALENDAR] Processing Outlook connection:', connection.id);
if (isTokenExpired(connection.expiresAt)) {
console.log('[CALENDAR] Outlook token expired, attempting refresh...');
const newToken = await refreshOutlookToken(connection);
if (newToken) {
accessToken = newToken;
} else {
console.error('[CALENDAR] Failed to refresh Outlook token, skipping connection');
continue;
}
}
// Get calendars to fetch
let calendarIds: string[] = [];
let calendars: any[] = [];
if (connection.calendars && Array.isArray(connection.calendars)) {
calendars = connection.calendars as any[];
calendarIds = calendars
.filter((c: any) => c.selected !== false)
.map((c: any) => c.id);
}
if (calendarIds.length > 0) {
console.log('[CALENDAR] Fetching Outlook events from', calendarIds.length, 'calendars');
for (const calendarId of calendarIds) {
try {
const outlookEvents = await getOutlookEvents(
accessToken,
calendarId,
timeMin,
timeMax
);
const calendarData = calendars.find(c => c.id === calendarId);
events = events.concat(outlookEvents.map((event: any) => ({
id: event.id,
title: event.summary || '(No Title)',
description: event.description,
start: event.start,
end: event.end,
location: event.location || '',
source: 'outlook' as const,
calendarId,
calendarTitle: calendarData?.title || 'Outlook Calendar',
backgroundColor: '#0078d4' // Outlook Blue
})));
} catch (calError) {
console.error(`[CALENDAR] Error fetching Outlook events from ${calendarId}:`, calError);
}
}
}
}
console.log('[CALENDAR] Total events for connection:', events.length);
allEvents.push(...events);
} catch (error) {
console.error(`[CALENDAR] Error fetching events from ${connection.provider} calendar:`, error);
// Continue with other connections even if one fails
}
}
console.log('[CALENDAR] Total events from all connections:', allEvents.length);
return allEvents;
};
/**
* Format event for display in the weekly view
*/
export const formatEventForDisplay = (event: CalendarEvent): CalendarEvent => {
// Ensure consistent structure for display
return {
id: event.id,
title: event.title,
description: event.description,
start: {
dateTime: event.start.dateTime,
date: event.start.date,
},
end: {
dateTime: event.end.dateTime,
date: event.end.date,
},
location: event.location,
source: event.source,
calendarId: event.calendarId,
calendarTitle: event.calendarTitle,
};
};
/**
* Merge calendar events with existing tasks to avoid duplicates
*/
export const mergeWithTasks = (
events: CalendarEvent[],
tasks: Array<{ id: string; title: string }>
): CalendarEvent[] => {
// Simple deduplication logic - if event title matches task title, consider it a duplicate
const taskTitles = new Set(tasks.map(task => task.title.toLowerCase()));
return events.filter(event => {
// Only show events where the title doesn't match an existing task title
return !taskTitles.has(event.title.toLowerCase());
});
};
/**
* Filter events by date range (week view)
*/
export const filterEventsByDateRange = (
events: CalendarEvent[],
startOfWeek: Date,
endOfWeek: Date
): CalendarEvent[] => {
const startTimestamp = startOfWeek.getTime();
const endTimestamp = endOfWeek.getTime();
return events.filter(event => {
const eventStart = event.start.dateTime ? new Date(event.start.dateTime).getTime() :
event.start.date ? new Date(event.start.date).getTime() : 0;
return eventStart >= startTimestamp && eventStart <= endTimestamp;
});
};
/**
* Determine event visibility based on user settings
*/
export const determineEventVisibility = (
event: CalendarEvent,
userSettings: {
showGoogleEvents?: boolean;
showAppleEvents?: boolean;
showPrivateEvents?: boolean;
}
): boolean => {
// Check if the user wants to see events from this provider
if ((event.source === 'google' && !userSettings.showGoogleEvents) ||
(event.source === 'apple' && !userSettings.showAppleEvents)) {
return false;
}
// Check if private events should be shown (this is a simplified check)
// In a real implementation, this would involve checking privacy settings
if (!userSettings.showPrivateEvents) {
// Simplified - in practice, private events would be filtered based on actual privacy flags
return true;
}
return true;
};
/**
* Create a new calendar event
*/
export const createCalendarEvent = async (
connection: CalendarConnection,
calendarId: string,
event: Partial<CalendarEvent>
): Promise<CalendarEvent> => {
if (connection.provider === 'google') {
// Check key fields
if (!event.title) throw new Error('Event title is required');
if (!event.start || !event.end) throw new Error('Event start and end times are required');
// Refresh token if needed
let accessToken = connection.accessToken;
if (isTokenExpired(connection.expiresAt)) {
const newToken = await refreshGoogleToken(connection);
if (newToken) accessToken = newToken;
else throw new Error('Failed to refresh token');
}
const oauth2Client = initializeGoogleOAuth(
process.env.GOOGLE_CLIENT_ID || '',
process.env.GOOGLE_CLIENT_SECRET || '',
process.env.GOOGLE_REDIRECT_URI || ''
);
// Map to Google format
const rrule = toRRule(event.recurrence);
const googleEvent: any = {
summary: event.title,
description: event.description,
start: event.start,
end: event.end,
location: event.location,
...(rrule ? { recurrence: [rrule] } : {}),
...(event.allDay ? { allDay: true } : {}),
...(event.url ? { source: { url: event.url, title: event.url } } : {}),
};
const createdEvent = await import('./google-calendar').then(m =>
m.createEvent(oauth2Client, accessToken, calendarId, googleEvent)
);
return {
id: createdEvent.id,
title: createdEvent.summary,
description: createdEvent.description,
start: createdEvent.start,
end: createdEvent.end,
location: createdEvent.location,
source: 'google',
calendarId,
calendarTitle: '', // We don't have this here, simpler to leave empty or fetch
} as CalendarEvent;
} else if (connection.provider === 'outlook') {
if (!event.title) throw new Error('Event title is required');
if (!event.start || !event.end) throw new Error('Event start and end times are required');
let accessToken = connection.accessToken;
if (isTokenExpired(connection.expiresAt)) {
const newToken = await refreshOutlookToken(connection);
if (newToken) accessToken = newToken;
else throw new Error('Failed to refresh token');
}
const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date();
const createdEvent = await createOutlookEvent(accessToken, calendarId, {
summary: event.title,
description: event.description,
start: event.start,
end: event.end,
location: event.location,
allDay: event.allDay,
recurrence: toOutlookRecurrence(event.recurrence, startDate),
});
return {
id: createdEvent.id,
title: createdEvent.summary,
description: createdEvent.description,
start: createdEvent.start,
end: createdEvent.end,
location: createdEvent.location,
allDay: createdEvent.allDay,
source: 'outlook',
calendarId,
calendarTitle: '',
} as CalendarEvent;
} else if (connection.provider === 'apple') {
const [email, appPassword] = connection.accessToken.split(':');
// We need to map our event format to what createEvent expects
// createEvent expects: { title, description?, location?, start: {dateTime?, date?}, end: ... }
// Our 'event' arg is Partial<CalendarEvent>, which matches well.
// However, event.start and event.end might be undefined in Partial, so we need checks.
// Handle all-day event normalization for Apple (requires 'date' property, not 'dateTime')
// AND exclusive end date (add 1 day if needed)
const start = { ...event.start! };
const end = { ...event.end! };
if (event.allDay) {
if (start.dateTime && !start.date) {
start.date = start.dateTime.split('T')[0];
delete start.dateTime;
}
if (end.dateTime && !end.date) {
end.date = end.dateTime.split('T')[0];
delete end.dateTime;
}
// Apple/iCloud requires DTEND to be the day AFTER the last day of the event
if (start.date && end.date && start.date === end.date) {
const d = new Date(end.date);
d.setDate(d.getDate() + 1);
end.date = d.toISOString().split('T')[0];
}
}
const createdEvent = await import('./apple-calendar').then(m =>
m.createEvent(email, appPassword, calendarId, {
title: event.title!,
description: event.description,
location: event.location,
url: event.url,
recurrence: event.recurrence,
start,
end
})
);
return {
id: createdEvent.id,
title: createdEvent.title,
description: createdEvent.description,
start: { dateTime: createdEvent.startDate },
end: { dateTime: createdEvent.endDate },
location: createdEvent.location,
source: 'apple',
calendarId,
calendarTitle: '', // Fetch if needed
} as CalendarEvent;
} else if (connection.provider === 'synology') {
const [username, password] = connection.accessToken.split(':');
const serverUrl = connection.refreshToken;
if (!username || !password || !serverUrl) throw new Error('Invalid Synology credentials');
if (!event.title) throw new Error('Event title is required');
if (!event.start || !event.end) throw new Error('Event start and end times are required');
const createdEvent = await import('./synology-calendar').then(m =>
m.createEvent(serverUrl, username, password, calendarId, {
title: event.title!,
description: event.description,
location: event.location,
url: event.url,
recurrence: event.recurrence,
start: event.start!,
end: event.end!
})
);
return {
id: createdEvent.id,
title: createdEvent.title,
description: createdEvent.description,
start: { dateTime: createdEvent.startDate },
end: { dateTime: createdEvent.endDate },
location: createdEvent.location,
source: 'synology',
calendarId,
calendarTitle: '',
} as CalendarEvent;
}
throw new Error(`Provider ${connection.provider} does not support creating events yet.`);
};
/**
* Update an existing calendar event
*/
export const updateCalendarEvent = async (
connection: CalendarConnection,
calendarId: string,
eventId: string,
event: Partial<CalendarEvent>
): Promise<CalendarEvent> => {
if (connection.provider === 'google') {
// Refresh token if needed
let accessToken = connection.accessToken;
if (isTokenExpired(connection.expiresAt)) {
const newToken = await refreshGoogleToken(connection);
if (newToken) accessToken = newToken;
else throw new Error('Failed to refresh token');
}
const oauth2Client = initializeGoogleOAuth(
process.env.GOOGLE_CLIENT_ID || '',
process.env.GOOGLE_CLIENT_SECRET || '',
process.env.GOOGLE_REDIRECT_URI || ''
);
// Map to Google format
const rrule = toRRule(event.recurrence);
const googleEvent: any = {};
if (event.title !== undefined) googleEvent.summary = event.title;
if (event.description !== undefined) googleEvent.description = event.description;
if (event.start !== undefined) googleEvent.start = event.start;
if (event.end !== undefined) googleEvent.end = event.end;
if (event.location !== undefined) googleEvent.location = event.location;
if (rrule) googleEvent.recurrence = [rrule];
if (event.url) googleEvent.source = { url: event.url, title: event.url };
// Google adds _date suffix for instances. Editing base series only.
const baseEventId = eventId.split('_')[0];
const updatedEvent = await import('./google-calendar').then(m =>
m.updateEvent(oauth2Client, accessToken, calendarId, baseEventId, googleEvent)
);
return {
id: updatedEvent.id,
title: updatedEvent.summary,
description: updatedEvent.description,
start: updatedEvent.start,
end: updatedEvent.end,
location: updatedEvent.location,
source: 'google',
calendarId,
calendarTitle: '',
} as CalendarEvent;
} else if (connection.provider === 'outlook') {
let accessToken = connection.accessToken;
if (isTokenExpired(connection.expiresAt)) {
const newToken = await refreshOutlookToken(connection);
if (newToken) accessToken = newToken;
else throw new Error('Failed to refresh token');
}
// Extract base series ID for Outlook
const baseEventId = eventId.includes('::') ? eventId.split('::')[0] : eventId;
const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date();
const updatedEvent = await updateOutlookEvent(accessToken, calendarId, baseEventId, {
summary: event.title,
description: event.description,
start: event.start,
end: event.end,
location: event.location,
allDay: event.allDay,
recurrence: toOutlookRecurrence(event.recurrence, startDate),
});
return {
id: updatedEvent.id,
title: updatedEvent.summary,
description: updatedEvent.description,
start: updatedEvent.start,
end: updatedEvent.end,
location: updatedEvent.location,
allDay: updatedEvent.allDay,
source: 'outlook',
calendarId,
calendarTitle: '',
} as CalendarEvent;
} else if (connection.provider === 'apple') {
const [email, appPassword] = connection.accessToken.split(':');
// Normalize for Apple updates too
if (event.allDay) {
if (event.start && event.start.dateTime && !event.start.date) {
event.start.date = event.start.dateTime.split('T')[0];
delete event.start.dateTime;
}
if (event.end && event.end.dateTime && !event.end.date) {
event.end.date = event.end.dateTime.split('T')[0];
delete event.end.dateTime;
}
if (event.start && event.end && event.start.date && event.end.date && event.start.date === event.end.date) {
const d = new Date(event.end.date);
d.setDate(d.getDate() + 1);
event.end.date = d.toISOString().split('T')[0];
}
}
const updatedEvent = await import('./apple-calendar').then(m =>
m.updateEvent(email, appPassword, calendarId, eventId, {
title: event.title,
description: event.description,
location: event.location,
url: event.url,
start: event.start,
end: event.end
})
);
return {
id: updatedEvent.id,
title: updatedEvent.title,
description: updatedEvent.description,
start: { dateTime: updatedEvent.startDate },
end: { dateTime: updatedEvent.endDate },
location: updatedEvent.location,
url: updatedEvent.url,
source: 'apple',
calendarId,
calendarTitle: '',
} as CalendarEvent;
} else if (connection.provider === 'synology') {
const [username, password] = connection.accessToken.split(':');
const serverUrl = connection.refreshToken;
if (!username || !password || !serverUrl) throw new Error('Invalid Synology credentials');
const updatedEvent = await import('./synology-calendar').then(m =>
m.updateEvent(serverUrl, username, password, calendarId, eventId, {
title: event.title,
description: event.description,
location: event.location,
url: event.url,
start: event.start,
end: event.end
})
);
return {
id: updatedEvent.id,
title: updatedEvent.title,
description: updatedEvent.description,
start: { dateTime: updatedEvent.startDate },
end: { dateTime: updatedEvent.endDate },
location: updatedEvent.location,
url: updatedEvent.url,
source: 'synology',
calendarId,
calendarTitle: '',
} as CalendarEvent;
}
throw new Error(`Provider ${connection.provider} does not support updating events yet.`);
};
/**
* Delete a calendar event
*/
export const deleteCalendarEvent = async (
connection: CalendarConnection,
calendarId: string,
eventId: string
): Promise<void> => {
if (connection.provider === 'google') {
// Refresh token if needed
let accessToken = connection.accessToken;
if (isTokenExpired(connection.expiresAt)) {
const newToken = await refreshGoogleToken(connection);
if (newToken) accessToken = newToken;
else throw new Error('Failed to refresh token');
}
const oauth2Client = initializeGoogleOAuth(
process.env.GOOGLE_CLIENT_ID || '',
process.env.GOOGLE_CLIENT_SECRET || '',
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)
);
return;
} else if (connection.provider === 'outlook') {
let accessToken = connection.accessToken;
if (isTokenExpired(connection.expiresAt)) {
const newToken = await refreshOutlookToken(connection);
if (newToken) accessToken = newToken;
else throw new Error('Failed to refresh token');
}
// Extract base series ID for Outlook
const baseEventId = eventId.includes('::') ? eventId.split('::')[0] : eventId;
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)
);
return;
} else if (connection.provider === 'synology') {
const [username, password] = connection.accessToken.split(':');
const serverUrl = connection.refreshToken;
if (!username || !password || !serverUrl) throw new Error('Invalid Synology credentials');
await import('./synology-calendar').then(m =>
m.deleteEvent(serverUrl, username, password, calendarId, eventId)
);
return;
}
throw new Error(`Provider ${connection.provider} does not support deleting events yet.`);
};