// 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 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); } // Sort events by date allEvents.sort((a, b) => new Date(a.date) - new Date(b.date)); console.log(`Retrieved ${allEvents.length} family events for ${year}-${month}`); 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 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 { // Delete event via CalDAV await this._deleteCalDAVEvent(userConfig, eventId); return true; } catch (error) { 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) { // 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}/`; } /** * Build CalDAV principals URL for a user * @private */ _buildCalDAVPrincipalsUrl(userConfig) { const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, ''); return `${baseUrl}/remote.php/dav/principals/users/${userConfig.username}/`; } /** * 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); 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) { 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); 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) { 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 the parsed date and ensure it's reasonable (not from 1981!) if (year >= 2020 && year <= 2030 && month >= 1 && month <= 12 && day >= 1 && day <= 31) { const parsedDate = new Date(year, month - 1, day); if (!isNaN(parsedDate.getTime()) && parsedDate.getFullYear() === year && parsedDate.getMonth() === (month - 1) && parsedDate.getDate() === day) { event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; } } } } } 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 the parsed date and ensure it's reasonable (not from 1981!) if (year >= 2020 && year <= 2030 && month >= 1 && month <= 12 && day >= 1 && day <= 31) { const parsedDate = new Date(year, month - 1, day); if (!isNaN(parsedDate.getTime()) && parsedDate.getFullYear() === year && parsedDate.getMonth() === (month - 1) && parsedDate.getDate() === day) { event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; } } } } } else if (line.startsWith('CREATED:')) { event.createdAt = line.substring(8); } else if (line.startsWith('LAST-MODIFIED:')) { event.updatedAt = line.substring(14); } } // 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 CalDAV 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' } }; 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 { 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`; } } module.exports = new CalendarService();