Add birthday support for mobile calendar
This commit is contained in:
@@ -81,6 +81,67 @@ class CalendarService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -96,11 +157,46 @@ class CalendarService {
|
||||
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}`);
|
||||
// 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);
|
||||
@@ -130,7 +226,7 @@ class CalendarService {
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Update event via CalDAV
|
||||
// Update event via CalDAV - let the server handle existence validation
|
||||
const updatedEvent = await this._updateCalDAVEvent(userConfig, event);
|
||||
|
||||
return updatedEvent;
|
||||
@@ -147,11 +243,19 @@ class CalendarService {
|
||||
*/
|
||||
async deleteEvent(userConfig, eventId) {
|
||||
try {
|
||||
// Delete event via CalDAV
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -310,12 +414,21 @@ class CalendarService {
|
||||
* @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}/`;
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,8 +436,17 @@ class CalendarService {
|
||||
* @private
|
||||
*/
|
||||
_buildCalDAVPrincipalsUrl(userConfig) {
|
||||
const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, '');
|
||||
return `${baseUrl}/remote.php/dav/principals/users/${userConfig.username}/`;
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -455,7 +577,7 @@ class CalendarService {
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
const events = this._parseCalDAVResponse(data, userConfig);
|
||||
const events = this._parseCalDAVResponse(data, userConfig, year, month);
|
||||
resolve(events);
|
||||
} catch (parseError) {
|
||||
reject(parseError);
|
||||
@@ -489,7 +611,7 @@ class CalendarService {
|
||||
* Parse CalDAV response and extract events
|
||||
* @private
|
||||
*/
|
||||
_parseCalDAVResponse(xmlData, userConfig) {
|
||||
_parseCalDAVResponse(xmlData, userConfig, requestedYear, requestedMonth) {
|
||||
try {
|
||||
// This is a basic parser - in a real implementation, you'd use a proper XML parser
|
||||
const events = [];
|
||||
@@ -510,7 +632,7 @@ class CalendarService {
|
||||
.replace(/"/g, '"')
|
||||
.trim();
|
||||
|
||||
const event = this._parseICSEvent(icsContent, userConfig);
|
||||
const event = this._parseICSEvent(icsContent, userConfig, requestedYear, requestedMonth);
|
||||
|
||||
if (event) {
|
||||
events.push(event);
|
||||
@@ -531,7 +653,7 @@ class CalendarService {
|
||||
* Parse ICS event content
|
||||
* @private
|
||||
*/
|
||||
_parseICSEvent(icsContent, userConfig) {
|
||||
_parseICSEvent(icsContent, userConfig, requestedYear, requestedMonth) {
|
||||
try {
|
||||
const lines = icsContent.split('\n').map(line => line.trim()).filter(line => line);
|
||||
|
||||
@@ -558,14 +680,25 @@ class CalendarService {
|
||||
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) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -581,14 +714,25 @@ class CalendarService {
|
||||
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) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -600,6 +744,29 @@ class CalendarService {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -612,7 +779,7 @@ class CalendarService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a CalDAV event
|
||||
* Update a calendar event
|
||||
* @private
|
||||
*/
|
||||
async _updateCalDAVEvent(userConfig, event) {
|
||||
@@ -635,7 +802,8 @@ class CalendarService {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(icsContent, 'utf8'),
|
||||
'User-Agent': 'OpenWall/1.0 CalDAV-Client'
|
||||
'User-Agent': 'OpenWall/1.0 CalDAV-Client',
|
||||
'If-Match': '*' // Allow overwriting existing events
|
||||
}
|
||||
};
|
||||
|
||||
@@ -655,6 +823,13 @@ class CalendarService {
|
||||
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}`));
|
||||
}
|
||||
@@ -764,6 +939,32 @@ 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();
|
||||
|
Reference in New Issue
Block a user