// Calendar Service for Nextcloud CalDAV integration // This is a basic implementation that can be enhanced with the @nextcloud/cdav-library const crypto = require('crypto'); const https = require('https'); const http = require('http'); const { URL } = require('url'); class CalendarService { /** * Create a calendar event * @param {Object} userConfig - Nextcloud user configuration * @param {string} date - Date in YYYY-MM-DD format * @param {string} text - Event description * @returns {Promise} Created event */ async createEvent(userConfig, date, text) { try { // Validate input parameters if (!userConfig) { throw new Error('User configuration is required'); } if (!userConfig.name) { throw new Error('User name is required'); } if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { throw new Error('Valid date in YYYY-MM-DD format is required'); } if (!text || text.trim().length === 0) { throw new Error('Event text is required'); } // Generate event ID and structure const eventId = crypto.randomUUID(); const event = { id: eventId, date: date, text: text.trim(), user: userConfig.name, summary: text.trim(), dtstart: this._formatDateForCalDAV(date), dtend: this._formatDateForCalDAV(date), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; // Create event via CalDAV const caldavEvent = await this._createCalDAVEvent(userConfig, event); return caldavEvent; } catch (error) { throw new Error(`Failed to create calendar event: ${error.message}`); } } /** * Get calendar events for a specific month * @param {Object} userConfig - Nextcloud user configuration * @param {string} year - Year (YYYY) * @param {string} month - Month (MM) * @returns {Promise} Array of events */ async getEventsForMonth(userConfig, year, month) { try { // Validate input parameters if (!userConfig || !userConfig.name) { throw new Error('Valid user configuration is required'); } if (!year || !/^\d{4}$/.test(year)) { throw new Error('Valid year in YYYY format is required'); } if (!month || !/^(0[1-9]|1[0-2])$/.test(month)) { throw new Error('Valid month in MM format is required'); } // Fetch events from CalDAV const events = await this._fetchCalDAVEvents(userConfig, year, month); return events; } catch (error) { throw new Error(`Failed to retrieve calendar events: ${error.message}`); } } /** * Get contact birthdays for a specific month * @param {Object} userConfig - Nextcloud user configuration * @param {string} year - Year (YYYY) * @param {string} month - Month (MM) * @returns {Promise} Array of birthday events */ async getContactBirthdaysForMonth(userConfig, year, month) { try { // Validate input parameters if (!userConfig || !userConfig.name) { console.warn('Valid user configuration is required for birthday fetch'); return []; } if (!userConfig.nextcloudUrl || !userConfig.username || !userConfig.password) { console.warn(`Missing required connection details for ${userConfig.name} birthday calendar`); return []; } if (!year || !/^\d{4}$/.test(year)) { throw new Error('Valid year in YYYY format is required'); } if (!month || !/^(0[1-9]|1[0-2])$/.test(month)) { throw new Error('Valid month in MM format is required'); } // Create a separate config for the contact_birthdays calendar const birthdayConfig = { name: userConfig.name, nextcloudUrl: userConfig.nextcloudUrl, username: userConfig.username, password: userConfig.password, calendarName: 'contact_birthdays' }; try { // Fetch birthday events from the contact_birthdays calendar const birthdayEvents = await this._fetchCalDAVEvents(birthdayConfig, year, month); // Mark these events as birthday events and add additional properties const processedBirthdayEvents = birthdayEvents.map(event => ({ ...event, id: `birthday-${event.id}`, // Prefix birthday event IDs to make them identifiable type: 'birthday', isBirthday: true, source: 'contact_birthdays', // Extract contact name from birthday text if possible contactName: this._extractContactNameFromBirthday(event.text || event.summary) })); return processedBirthdayEvents; } catch (error) { // If contact_birthdays calendar doesn't exist or is inaccessible, return empty array console.warn(`Could not fetch contact birthdays for ${userConfig.name}: ${error.message}`); return []; } } catch (error) { console.error(`Failed to retrieve contact birthdays: ${error.message}`); return []; // Return empty array instead of throwing to not break other calendar functionality } } /** * Get calendar events for all users (family view) * @param {Array} userConfigs - Array of user configurations * @param {string} year - Year (YYYY) * @param {string} month - Month (MM) * @returns {Promise} Array of events from all users */ async getFamilyEventsForMonth(userConfigs, year, month) { try { const allEvents = []; for (const userConfig of userConfigs) { const userEvents = await this.getEventsForMonth(userConfig, year, month); allEvents.push(...userEvents); } // Fetch contact birthdays only from the first user to avoid duplicates // Birthday calendars are typically shared/synchronized across users if (userConfigs.length > 0) { const birthdayEvents = await this.getContactBirthdaysForMonth(userConfigs[0], year, month); // Filter out duplicate birthday events and merge with regular events that might be on the same day const seenBirthdays = new Set(); const uniqueBirthdayEvents = birthdayEvents.filter(event => { const birthdayKey = `${event.date}-${(event.text || '').trim()}-${(event.contactName || '').trim()}`; if (!seenBirthdays.has(birthdayKey)) { seenBirthdays.add(birthdayKey); return true; } return false; }).map(event => ({ ...event, // Use the first user's name for birthday events or mark as shared user: userConfigs[0].name, isSharedBirthday: true })); allEvents.push(...uniqueBirthdayEvents); } // Sort events by date string first (YYYY-MM-DD format) - this ensures proper chronological ordering allEvents.sort((a, b) => { // First, compare dates as strings to avoid timezone issues if (a.date !== b.date) { return a.date.localeCompare(b.date); } // If dates are the same, sort by type (birthdays first) if (a.type === 'birthday' && b.type !== 'birthday') return -1; if (a.type !== 'birthday' && b.type === 'birthday') return 1; // Then sort by event text for consistent ordering return (a.text || '').localeCompare(b.text || ''); }); return allEvents; } catch (error) { console.error('Error retrieving family calendar events:', error); throw new Error('Failed to retrieve family calendar events'); } } /** * Update a calendar event * @param {Object} userConfig - Nextcloud user configuration * @param {string} eventId - Event ID * @param {string} date - New date * @param {string} text - New text * @returns {Promise} Updated event */ async updateEvent(userConfig, eventId, date, text) { try { // Build updated event object const event = { id: eventId, date: date, text: text, user: userConfig.name, summary: text, dtstart: this._formatDateForCalDAV(date), dtend: this._formatDateForCalDAV(date), updatedAt: new Date().toISOString() }; // Update event via CalDAV - let the server handle existence validation const updatedEvent = await this._updateCalDAVEvent(userConfig, event); return updatedEvent; } catch (error) { throw error; } } /** * Delete a calendar event * @param {Object} userConfig - Nextcloud user configuration * @param {string} eventId - Event ID * @returns {Promise} Success status */ async deleteEvent(userConfig, eventId) { try { // Check if this is a birthday event that shouldn't be deleted if (eventId && eventId.includes('birthday-')) { console.warn(`Blocked attempt to delete birthday event ${eventId}`); throw new Error('Birthday events cannot be deleted from the calendar interface'); } // Simply attempt the deletion - let CalDAV server handle existence validation // The previous approach of checking multiple months was too complex and unreliable await this._deleteCalDAVEvent(userConfig, eventId); return true; } catch (error) { console.error(`Failed to delete event ${eventId} for user ${userConfig.name}: ${error.message}`); throw error; } } /** * Test connection to Nextcloud instance * @param {Object} userConfig - Nextcloud user configuration * @returns {Promise} Connection status */ async testConnection(userConfig) { try { // Validate user configuration if (!userConfig.nextcloudUrl || !userConfig.username || !userConfig.password) { return false; } // Basic HTTP test to verify Nextcloud instance is reachable const url = new URL(userConfig.nextcloudUrl); const options = { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: '/status.php', method: 'GET', timeout: 5000, headers: { 'User-Agent': 'OpenWall/1.0' } }; const protocol = url.protocol === 'https:' ? https : http; const result = await new Promise((resolve) => { const req = protocol.request(options, (res) => { let data = ''; res.on('data', chunk => { data += chunk; }); res.on('end', () => { try { const status = JSON.parse(data); const isNextcloud = status.productname && status.productname.toLowerCase().includes('nextcloud'); resolve(isNextcloud); } catch (e) { resolve(false); } }); }); req.on('error', () => { resolve(false); }); req.on('timeout', () => { req.destroy(); resolve(false); }); req.end(); }); // If basic connection works, test CalDAV endpoint if (result) { const caldavResult = await this._testCalDAVConnection(userConfig); return caldavResult; } return result; } catch (error) { return false; } } /** * Format date for CalDAV (YYYYMMDD format for all-day events) * @private */ _formatDateForCalDAV(dateString) { const date = new Date(dateString); return date.toISOString().slice(0, 10).replace(/-/g, ''); } /** * Test CalDAV connection by trying to access the calendar endpoint * @private */ async _testCalDAVConnection(userConfig) { try { // Try to access the CalDAV principal endpoint const caldavUrl = this._buildCalDAVUrl(userConfig); const url = new URL(caldavUrl); const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64'); const options = { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname, method: 'PROPFIND', timeout: 10000, headers: { 'Authorization': `Basic ${auth}`, 'Content-Type': 'application/xml; charset=utf-8', 'Depth': '0', 'User-Agent': 'OpenWall/1.0 CalDAV-Client' } }; const protocol = url.protocol === 'https:' ? https : http; const body = ` `; return new Promise((resolve) => { const req = protocol.request(options, (res) => { let data = ''; res.on('data', chunk => { data += chunk; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(true); } else { resolve(false); } }); }); req.on('error', () => { resolve(false); }); req.on('timeout', () => { req.destroy(); resolve(false); }); req.write(body); req.end(); }); } catch (error) { return false; } } /** * Build CalDAV URL for a user * @private */ _buildCalDAVUrl(userConfig) { try { // Validate userConfig if (!userConfig || !userConfig.nextcloudUrl || !userConfig.username) { throw new Error('Invalid user configuration: missing required fields'); } // Remove trailing slash from nextcloudUrl if present const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, ''); // Build CalDAV principal URL - this is the standard Nextcloud CalDAV path const calendarName = userConfig.calendarName || 'personal'; return `${baseUrl}/remote.php/dav/calendars/${userConfig.username}/${calendarName}/`; } catch (error) { throw new Error(`Failed to build CalDAV URL: ${error.message}`); } } /** * Build CalDAV principals URL for a user * @private */ _buildCalDAVPrincipalsUrl(userConfig) { try { // Validate userConfig if (!userConfig || !userConfig.nextcloudUrl || !userConfig.username) { throw new Error('Invalid user configuration: missing required fields'); } const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, ''); return `${baseUrl}/remote.php/dav/principals/users/${userConfig.username}/`; } catch (error) { throw new Error(`Failed to build CalDAV principals URL: ${error.message}`); } } /** * Create a CalDAV event * @private */ async _createCalDAVEvent(userConfig, event) { try { const caldavUrl = this._buildCalDAVUrl(userConfig); const eventUrl = `${caldavUrl}${event.id}.ics`; const url = new URL(eventUrl); const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64'); const icsContent = this._generateICSEvent(event); const options = { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname, method: 'PUT', timeout: 10000, headers: { 'Authorization': `Basic ${auth}`, 'Content-Type': 'text/calendar; charset=utf-8', 'Content-Length': Buffer.byteLength(icsContent, 'utf8'), 'User-Agent': 'OpenWall/1.0 CalDAV-Client', 'If-None-Match': '*' // Prevent overwriting existing events } }; const protocol = url.protocol === 'https:' ? https : http; return new Promise((resolve, reject) => { const req = protocol.request(options, (res) => { let data = ''; res.on('data', chunk => { data += chunk; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(event); } else if (res.statusCode === 401) { reject(new Error('CalDAV authentication failed')); } else if (res.statusCode === 412) { reject(new Error('CalDAV event already exists')); } else { reject(new Error(`CalDAV event creation failed with status ${res.statusCode}: ${data}`)); } }); }); req.on('error', (error) => { reject(new Error(`CalDAV request failed: ${error.message}`)); }); req.on('timeout', () => { req.destroy(); reject(new Error('CalDAV request timeout')); }); req.write(icsContent); req.end(); }); } catch (error) { throw error; } } /** * Fetch CalDAV events * @private */ async _fetchCalDAVEvents(userConfig, year, month) { try { const caldavUrl = this._buildCalDAVUrl(userConfig); const url = new URL(caldavUrl); const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64'); // Build date range for the month const startDate = new Date(parseInt(year), parseInt(month) - 1, 1); const endDate = new Date(parseInt(year), parseInt(month), 0); const startDateStr = startDate.toISOString().slice(0, 10).replace(/-/g, '') + 'T000000Z'; const endDateStr = endDate.toISOString().slice(0, 10).replace(/-/g, '') + 'T235959Z'; const reportBody = ` `; const options = { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname, method: 'REPORT', timeout: 10000, headers: { 'Authorization': `Basic ${auth}`, 'Content-Type': 'application/xml; charset=utf-8', 'Content-Length': Buffer.byteLength(reportBody, 'utf8'), 'Depth': '1', 'User-Agent': 'OpenWall/1.0 CalDAV-Client' } }; const protocol = url.protocol === 'https:' ? https : http; return new Promise((resolve, reject) => { const req = protocol.request(options, (res) => { let data = ''; res.on('data', chunk => { data += chunk; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { try { const events = this._parseCalDAVResponse(data, userConfig, year, month); resolve(events); } catch (parseError) { reject(parseError); } } else if (res.statusCode === 401) { reject(new Error('CalDAV authentication failed')); } else { reject(new Error(`CalDAV event fetch failed with status ${res.statusCode}: ${data}`)); } }); }); req.on('error', (error) => { reject(new Error(`CalDAV request failed: ${error.message}`)); }); req.on('timeout', () => { req.destroy(); reject(new Error('CalDAV request timeout')); }); req.write(reportBody); req.end(); }); } catch (error) { throw error; } } /** * Parse CalDAV response and extract events * @private */ _parseCalDAVResponse(xmlData, userConfig, requestedYear, requestedMonth) { try { // This is a basic parser - in a real implementation, you'd use a proper XML parser const events = []; // Look for calendar-data with different possible namespace prefixes const eventMatches = xmlData.match(/<(?:C:|cal:)?calendar-data[^>]*>([\s\S]*?)<\/(?:C:|cal:)?calendar-data>/gi); if (eventMatches) { eventMatches.forEach((match, index) => { try { // Extract the VCALENDAR content - handle CDATA and escaped content let icsContent = match .replace(//g, '$1') // Remove CDATA wrapper .replace(/<[^>]*>/g, '') // Remove XML tags .replace(/</g, '<') // Decode HTML entities .replace(/>/g, '>') .replace(/&/g, '&') .replace(/"/g, '"') .trim(); const event = this._parseICSEvent(icsContent, userConfig, requestedYear, requestedMonth); if (event) { events.push(event); } } catch (eventError) { // Skip individual events that fail to parse } }); } return events; } catch (error) { throw new Error(`Failed to parse CalDAV response: ${error.message}`); } } /** * Parse ICS event content * @private */ _parseICSEvent(icsContent, userConfig, requestedYear, requestedMonth) { try { const lines = icsContent.split('\n').map(line => line.trim()).filter(line => line); const event = { user: userConfig.name, source: 'caldav' }; for (const line of lines) { if (line.startsWith('UID:')) { event.id = line.substring(4); } else if (line.startsWith('SUMMARY:')) { event.text = line.substring(8); event.summary = event.text; } else if (line.startsWith('DTSTART')) { const dateValue = line.split(':')[1]; event.dtstart = dateValue; // Parse date more robustly if (dateValue && dateValue.length >= 8) { // Handle different DTSTART formats: YYYYMMDD, YYYYMMDDTHHMMSSZ, etc. const dateOnly = dateValue.substring(0, 8); if (/^\d{8}$/.test(dateOnly)) { const year = parseInt(dateOnly.substring(0, 4)); const month = parseInt(dateOnly.substring(4, 6)); const day = parseInt(dateOnly.substring(6, 8)); // Validate month and day are reasonable (don't restrict year for recurring events) if (month >= 1 && month <= 12 && day >= 1 && day <= 31) { // Create date and validate it's correct const testDate = new Date(year, month - 1, day); if (!isNaN(testDate.getTime()) && testDate.getMonth() === (month - 1) && testDate.getDate() === day) { event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; event.originalYear = year; event.originalDate = event.date; } else { // If date validation fails, try with current year const currentYear = new Date().getFullYear(); const fallbackDate = new Date(currentYear, month - 1, day); if (!isNaN(fallbackDate.getTime())) { event.date = `${currentYear}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; event.originalYear = year; event.isFallback = true; } } } } } } else if (line.startsWith('DTEND')) { const dateValue = line.split(':')[1]; event.dtend = dateValue; // If we don't have a start date yet, try to get it from end date if (!event.date && dateValue && dateValue.length >= 8) { const dateOnly = dateValue.substring(0, 8); if (/^\d{8}$/.test(dateOnly)) { const year = parseInt(dateOnly.substring(0, 4)); const month = parseInt(dateOnly.substring(4, 6)); const day = parseInt(dateOnly.substring(6, 8)); // Validate month and day are reasonable (don't restrict year for recurring events) if (month >= 1 && month <= 12 && day >= 1 && day <= 31) { // Create date and validate it's correct const testDate = new Date(year, month - 1, day); if (!isNaN(testDate.getTime()) && testDate.getMonth() === (month - 1) && testDate.getDate() === day) { event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; event.originalYear = year; event.originalDate = event.date; } else { // If date validation fails, try with current year const currentYear = new Date().getFullYear(); const fallbackDate = new Date(currentYear, month - 1, day); if (!isNaN(fallbackDate.getTime())) { event.date = `${currentYear}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; event.originalYear = year; event.isFallback = true; } } } } } } else if (line.startsWith('CREATED:')) { event.createdAt = line.substring(8); } else if (line.startsWith('LAST-MODIFIED:')) { event.updatedAt = line.substring(14); } } // Normalize date to the requested year/month for proper grouping if (event.date && requestedYear && requestedMonth) { const eventDate = new Date(event.date); // Validate that the date parsed correctly if (!isNaN(eventDate.getTime())) { const eventDay = eventDate.getDate(); // Validate day is reasonable for the month if (eventDay >= 1 && eventDay <= 31) { // Always use the requested year for proper grouping, regardless of original event year const normalizedDate = `${requestedYear}-${requestedMonth.padStart(2, '0')}-${eventDay.toString().padStart(2, '0')}`; // Validate the normalized date is valid const testNormalizedDate = new Date(normalizedDate); if (!isNaN(testNormalizedDate.getTime())) { event.date = normalizedDate; event.isNormalized = true; } } } } // Only return events with required fields and valid dates if (event.id && event.text && event.date) { return event; } else { return null; } } catch (error) { return null; } } /** * Update a calendar event * @private */ async _updateCalDAVEvent(userConfig, event) { try { const caldavUrl = this._buildCalDAVUrl(userConfig); const eventUrl = `${caldavUrl}${event.id}.ics`; const url = new URL(eventUrl); const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64'); const icsContent = this._generateICSEvent(event); const options = { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname, method: 'PUT', timeout: 10000, headers: { 'Authorization': `Basic ${auth}`, 'Content-Type': 'text/calendar; charset=utf-8', 'Content-Length': Buffer.byteLength(icsContent, 'utf8'), 'User-Agent': 'OpenWall/1.0 CalDAV-Client', 'If-Match': '*' // Allow overwriting existing events } }; const protocol = url.protocol === 'https:' ? https : http; return new Promise((resolve, reject) => { const req = protocol.request(options, (res) => { let data = ''; res.on('data', chunk => { data += chunk; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(event); } else if (res.statusCode === 401) { reject(new Error('CalDAV authentication failed')); } else if (res.statusCode === 404) { reject(new Error('Event not found')); } else if (res.statusCode === 400 && data.includes('uid already exists')) { // If UID conflict, try to delete and recreate this._deleteCalDAVEvent(userConfig, event.id) .then(() => this._createCalDAVEvent(userConfig, event)) .then(resolve) .catch(reject); return; } else { reject(new Error(`CalDAV event update failed with status ${res.statusCode}: ${data}`)); } }); }); req.on('error', (error) => { reject(new Error(`CalDAV request failed: ${error.message}`)); }); req.on('timeout', () => { req.destroy(); reject(new Error('CalDAV request timeout')); }); req.write(icsContent); req.end(); }); } catch (error) { throw error; } } /** * Delete a CalDAV event * @private */ async _deleteCalDAVEvent(userConfig, eventId) { try { const caldavUrl = this._buildCalDAVUrl(userConfig); const eventUrl = `${caldavUrl}${eventId}.ics`; const url = new URL(eventUrl); const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64'); const options = { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname, method: 'DELETE', timeout: 10000, headers: { 'Authorization': `Basic ${auth}`, 'User-Agent': 'OpenWall/1.0 CalDAV-Client' } }; const protocol = url.protocol === 'https:' ? https : http; return new Promise((resolve, reject) => { const req = protocol.request(options, (res) => { let data = ''; res.on('data', chunk => { data += chunk; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(true); } else if (res.statusCode === 401) { reject(new Error('CalDAV authentication failed')); } else if (res.statusCode === 404) { reject(new Error('Event not found')); } else { reject(new Error(`CalDAV event deletion failed with status ${res.statusCode}: ${data}`)); } }); }); req.on('error', (error) => { reject(new Error(`CalDAV request failed: ${error.message}`)); }); req.on('timeout', () => { req.destroy(); reject(new Error('CalDAV request timeout')); }); req.end(); }); } catch (error) { throw error; } } /** * Generate ICS event content * @private */ _generateICSEvent(event) { const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; const created = event.createdAt ? new Date(event.createdAt).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : now; const modified = event.updatedAt ? new Date(event.updatedAt).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : now; return `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//OpenWall//Calendar//EN BEGIN:VEVENT UID:${event.id} DTSTART;VALUE=DATE:${event.dtstart} DTEND;VALUE=DATE:${event.dtend} SUMMARY:${event.summary} DESCRIPTION:${event.text} CREATED:${created} LAST-MODIFIED:${modified} DTSTAMP:${now} END:VEVENT END:VCALENDAR`; } /** * Extract contact name from birthday event text * @private */ _extractContactNameFromBirthday(text) { if (!text) return null; // Common birthday text patterns in different languages const patterns = [ /^(.+?)(?:'s|s)?\s*(?:Birthday|Geburtstag|birthday|geburtstag)$/i, /^(?:Birthday|Geburtstag)\s*[:-]?\s*(.+)$/i, /^(.+?)\s*\(.*\)$/i, // Remove parentheses content /^(.+?)(?:\s*-\s*Birthday)?$/i ]; for (const pattern of patterns) { const match = text.match(pattern); if (match && match[1]) { return match[1].trim(); } } // If no pattern matches, return the original text (minus common birthday words) return text.replace(/\b(Birthday|Geburtstag|birthday|geburtstag)\b/gi, '').trim() || text; } } module.exports = new CalendarService();