971 lines
32 KiB
JavaScript
971 lines
32 KiB
JavaScript
// 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<Object>} 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>} 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>} 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>} 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<Object>} 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<boolean>} 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<boolean>} 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 = `<?xml version="1.0" encoding="utf-8" ?>
|
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
<D:prop>
|
|
<D:resourcetype/>
|
|
<D:displayname/>
|
|
<C:calendar-description/>
|
|
</D:prop>
|
|
</D:propfind>`;
|
|
|
|
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 = `<?xml version="1.0" encoding="utf-8" ?>
|
|
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
<D:prop>
|
|
<D:getetag/>
|
|
<C:calendar-data/>
|
|
</D:prop>
|
|
<C:filter>
|
|
<C:comp-filter name="VCALENDAR">
|
|
<C:comp-filter name="VEVENT">
|
|
<C:time-range start="${startDateStr}" end="${endDateStr}"/>
|
|
</C:comp-filter>
|
|
</C:comp-filter>
|
|
</C:filter>
|
|
</C:calendar-query>`;
|
|
|
|
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(/<!\[CDATA\[([\s\S]*?)\]\]>/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();
|