Skip to main content
Glama
calendar-service.ts11.3 kB
/** * Calendar Service for interacting with Nextcloud calendars via CalDAV */ import { NextcloudConfig } from '../../config/config.js'; import { Calendar, CalendarUtils } from '../../models/index.js'; import { createLogger } from '../logger.js'; import { XmlService, CalDavXmlBuilder } from '../xml/index.js'; import { CalendarHttpClient } from './http-client.js'; import * as PropertyParser from './property-parser.js'; export class CalendarService { private config: NextcloudConfig; private httpClient: CalendarHttpClient; private logger = createLogger('CalendarService'); private xmlService: XmlService; private caldavXmlBuilder: CalDavXmlBuilder; constructor(config: NextcloudConfig) { this.config = config; if (!this.config.baseUrl || !this.config.username || !this.config.appToken) { throw new Error('Nextcloud configuration is incomplete'); } // Remove trailing slash if present const baseUrl = this.config.baseUrl.replace(/\/$/, ''); // Initialize HTTP client this.httpClient = new CalendarHttpClient(baseUrl, this.config.username, this.config.appToken); // Initialize XML service this.xmlService = new XmlService(); this.caldavXmlBuilder = new CalDavXmlBuilder(this.xmlService); // Log initialization without sensitive details this.logger.info('CalendarService initialized successfully', { baseUrl: baseUrl, username: this.config.username, }); } /** * Get a list of all calendars for the user * @returns Promise<Calendar[]> List of calendars */ async getCalendars(): Promise<Calendar[]> { try { // Make request to Nextcloud CalDAV endpoint with PROPFIND const xmlResponse = await this.httpClient.propfind( this.caldavXmlBuilder.buildPropfindRequest(), ); // Parse XML response const xmlData = await this.xmlService.parseXml(xmlResponse); // Extract calendar information const calendars: Calendar[] = []; // Get responses from the multistatus element const calDavResponses = this.caldavXmlBuilder.parseMultistatus(xmlData); // Process each response for (const response of calDavResponses) { try { // Convert CalDavResponse to Record<string, unknown> for compatibility const responseObj: Record<string, unknown> = { href: response.href, status: response.status, ...response.properties, }; const calendar = PropertyParser.parseCalendarResponse( responseObj, this.httpClient.getBaseUrl(), this.httpClient.getCalDavUrl(), this.config.username, ); if (calendar) { calendars.push(calendar); } } catch (parseError) { // Log but continue processing other responses this.logger.warn('Error parsing calendar response:', parseError); } } // Log summary of what we found this.logger.info(`Found ${calendars.length} calendars`); return calendars; } catch (error) { this.logger.error('Error fetching calendars:', error); throw new Error(`Failed to fetch calendars: ${(error as Error).message}`); } } /** * Create a new calendar * @param newCalendar Calendar object with properties for the new calendar * @returns Promise<Calendar> The created calendar with server-assigned properties */ async createCalendar(newCalendar: Omit<Calendar, 'id' | 'url'>): Promise<Calendar> { try { // Log with appropriate masking of sensitive data this.logger.debug('Creating new calendar:', { displayName: newCalendar.displayName }); if (!newCalendar.displayName) { this.logger.warn('Attempted to create calendar without required displayName'); throw new Error('Calendar display name is required'); } // Generate a URL-safe calendar ID from the display name const calendarId = encodeURIComponent( newCalendar.displayName .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''), ) + '-' + Date.now().toString(36); // First, make the MKCALENDAR request to create the calendar this.logger.debug('Sending MKCALENDAR request to create calendar directory'); await this.httpClient.mkcalendar( calendarId, this.caldavXmlBuilder.buildMkcalendarRequest(newCalendar.displayName), ); this.logger.debug('MKCALENDAR request successful'); // Then, set additional properties with PROPPATCH this.logger.debug('Sending PROPPATCH request to set additional calendar properties'); await this.httpClient.proppatch( calendarId, this.caldavXmlBuilder.buildProppatchRequest({ displayName: newCalendar.displayName, color: newCalendar.color || '#0082c9', category: newCalendar.category || undefined, focusPriority: newCalendar.focusPriority || undefined, }), ); this.logger.debug('PROPPATCH request successful'); // Create and return the calendar object with server-assigned properties this.logger.debug('Creating Calendar object from server-assigned properties'); const calendar = CalendarUtils.toCalendar({ id: calendarId, displayName: newCalendar.displayName, color: newCalendar.color || '#0082c9', owner: this.config.username, isDefault: false, isShared: false, isReadOnly: false, permissions: { canRead: true, canWrite: true, canShare: true, canDelete: true, }, url: `${this.httpClient.getCalDavUrl()}${calendarId}/`, category: newCalendar.category, focusPriority: newCalendar.focusPriority, metadata: newCalendar.metadata, }); this.logger.info(`Calendar created successfully: ${calendarId} (${newCalendar.displayName})`); return calendar; } catch (error) { // Log error details for debugging, but don't expose them to the client this.logger.error('Error creating calendar:', error); throw new Error(`Failed to create calendar: ${(error as Error).message}`); } } /** * Update an existing calendar * @param calendarId ID of the calendar to update * @param updates Calendar object with updated properties * @returns Promise<Calendar> The updated calendar */ async updateCalendar(calendarId: string, updates: Partial<Calendar>): Promise<Calendar> { try { this.logger.debug(`Updating calendar ${calendarId}`, updates); // First, verify the calendar exists by trying to get it this.logger.debug(`Fetching existing calendar: ${calendarId}`); const calendars = await this.getCalendars(); const existingCalendar = calendars.find((cal) => cal.id === calendarId); if (!existingCalendar) { this.logger.warn(`Attempted to update non-existent calendar: ${calendarId}`); throw new Error(`Calendar with ID ${calendarId} not found`); } // Check if user has write permissions if (existingCalendar.isReadOnly || !existingCalendar.permissions.canWrite) { this.logger.warn( `Permission denied when updating calendar ${calendarId} - isReadOnly: ${existingCalendar.isReadOnly}, canWrite: ${existingCalendar.permissions.canWrite}`, ); throw new Error('You do not have permission to modify this calendar'); } this.logger.debug(`Permission check passed for calendar ${calendarId}`); // Only send PROPPATCH if there are properties to update if (Object.keys(updates).length > 0) { // Create properties object for CalendarProperties interface const calendarProperties = { displayName: updates.displayName, color: updates.color, category: updates.category !== null ? updates.category : undefined, focusPriority: updates.focusPriority !== null ? updates.focusPriority : undefined, }; this.logger.debug(`Sending PROPPATCH request to update calendar ${calendarId} properties`); await this.httpClient.proppatch( calendarId, this.caldavXmlBuilder.buildProppatchRequest(calendarProperties), ); this.logger.debug('PROPPATCH request for calendar update successful'); } // Return the updated calendar object this.logger.debug(`Creating updated Calendar object for ${calendarId}`); const updatedCalendar = CalendarUtils.toCalendar({ ...CalendarUtils.fromCalendar(existingCalendar), ...updates, id: calendarId, // Preserve original ID url: existingCalendar.url, // Preserve original URL // Preserve original ownership and permission info owner: existingCalendar.owner, isDefault: existingCalendar.isDefault, isShared: existingCalendar.isShared, isReadOnly: existingCalendar.isReadOnly, permissions: existingCalendar.permissions, }); this.logger.info( `Calendar updated successfully: ${calendarId} (${updatedCalendar.displayName})`, ); return updatedCalendar; } catch (error) { // Log error details for debugging, but don't expose them to the client this.logger.error('Error updating calendar:', error); throw new Error(`Failed to update calendar: ${(error as Error).message}`); } } /** * Delete a calendar * @param calendarId ID of the calendar to delete * @returns Promise<boolean> True if calendar was deleted successfully */ async deleteCalendar(calendarId: string): Promise<boolean> { try { this.logger.debug(`Deleting calendar ${calendarId}`); // First, verify the calendar exists and check permissions this.logger.debug(`Verifying calendar ${calendarId} exists and checking permissions`); const calendars = await this.getCalendars(); const calendar = calendars.find((cal) => cal.id === calendarId); if (!calendar) { this.logger.warn(`Attempted to delete non-existent calendar: ${calendarId}`); throw new Error(`Calendar with ID ${calendarId} not found`); } // Check if user has delete permissions if (!calendar.permissions.canDelete) { this.logger.warn( `Permission denied when deleting calendar ${calendarId} - canDelete: ${calendar.permissions.canDelete}`, ); throw new Error('You do not have permission to delete this calendar'); } // Prevent deletion of the default calendar if (calendar.isDefault) { this.logger.warn(`Attempted to delete default calendar: ${calendarId}`); throw new Error('The default calendar cannot be deleted'); } this.logger.debug(`Checks passed for calendar deletion: ${calendarId}`); // Send DELETE request await this.httpClient.deleteCalendar(calendarId); this.logger.info(`Calendar ${calendarId} deleted successfully`); return true; } catch (error) { // Log error details for debugging, but don't expose them to the client this.logger.error('Error deleting calendar:', error); throw new Error(`Failed to delete calendar: ${(error as Error).message}`); } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Cheffromspace/mcp-nextcloud-calendar'

If you have feedback or need assistance with the MCP directory API, please join our Discord server