@@ -469,24 +556,54 @@ const CalendarEvent = ({
) : (
<>
-
{event.text}
+
+ {isBirthday && (
+
+
+
+ )}
+ {isBirthday && event.contactName ? event.contactName : event.text}
+ {isBirthday && (
+ Geburtstag
+ )}
+
{showUser && (
{event.user}
+ {isBirthday && (Kontakte)}
+ {!canEdit && !isBirthday && (nur lesen)}
)}
-
-
-
-
+ {canEdit && (
+
+
+ {canDelete && (
+
+ )}
+
+ )}
+ {isBirthday && (
+
+ )}
+ {!canEdit && !isBirthday && (
+
+ )}
>
)}
diff --git a/mobile-calendar/src/App.sass b/mobile-calendar/src/App.sass
index 0ea8478..b7ff8c7 100644
--- a/mobile-calendar/src/App.sass
+++ b/mobile-calendar/src/App.sass
@@ -477,6 +477,17 @@ body
&::before
width: 8px
+ &.birthday-event
+ background: linear-gradient(135deg, rgba(255, 107, 157, 0.1) 0%, rgba(255, 182, 193, 0.15) 100%)
+ border-left: 4px solid #ff6b9d
+
+ &::before
+ background: linear-gradient(to bottom, #ff6b9d, rgba(255, 107, 157, 0.3))
+
+ &:hover
+ background: linear-gradient(135deg, rgba(255, 107, 157, 0.15) 0%, rgba(255, 182, 193, 0.2) 100%)
+ box-shadow: 0 4px 20px rgba(255, 107, 157, 0.3)
+
.event-content
flex: 1
min-width: 0
@@ -490,6 +501,28 @@ body
margin-bottom: var(--spacing-xs)
word-break: break-word
line-height: 1.4
+ display: flex
+ align-items: center
+ gap: var(--spacing-xs)
+ flex-wrap: wrap
+
+ .birthday-icon
+ color: #ff6b9d
+ font-size: 1.1rem
+ animation: birthday-bounce 2s infinite
+ filter: drop-shadow(0 2px 4px rgba(255, 107, 157, 0.3))
+
+ .birthday-label
+ background: linear-gradient(135deg, #ff6b9d 0%, #e91e63 100%)
+ color: white
+ font-size: 0.625rem
+ font-weight: 600
+ padding: 2px 6px
+ border-radius: 8px
+ text-transform: uppercase
+ letter-spacing: 0.5px
+ margin-left: auto
+ box-shadow: 0 2px 6px rgba(255, 107, 157, 0.3)
.event-user
font-size: 0.875rem
@@ -507,6 +540,17 @@ body
background: currentColor
opacity: 0.6
+ .event-source
+ font-size: 0.75rem
+ opacity: 0.7
+ font-style: italic
+
+ .read-only-indicator
+ font-size: 0.75rem
+ opacity: 0.7
+ font-style: italic
+ color: var(--text-muted)
+
.event-actions
display: flex
gap: var(--spacing-xs)
@@ -514,6 +558,36 @@ body
position: relative
z-index: 1
+ .birthday-indicator
+ display: flex
+ align-items: center
+ justify-content: center
+ width: 36px
+ height: 36px
+ border-radius: 50%
+ background: linear-gradient(135deg, #ff6b9d 0%, #e91e63 100%)
+ color: white
+ box-shadow: 0 3px 10px rgba(255, 107, 157, 0.4)
+ animation: birthday-glow 3s infinite
+
+ .gift-icon
+ font-size: 0.875rem
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2))
+
+ .read-only-indicator
+ display: flex
+ align-items: center
+ justify-content: center
+ width: 36px
+ height: 36px
+ border-radius: 50%
+ background: linear-gradient(135deg, var(--text-muted) 0%, var(--text-secondary) 100%)
+ color: white
+ opacity: 0.7
+
+ .read-only-text
+ font-size: 0.875rem
+
.edit-form
width: 100%
display: flex
@@ -552,6 +626,18 @@ body
justify-content: flex-end
margin-top: var(--spacing-sm)
+@keyframes birthday-bounce
+ 0%, 100%
+ transform: translateY(0) scale(1)
+ 50%
+ transform: translateY(-2px) scale(1.1)
+
+@keyframes birthday-glow
+ 0%, 100%
+ box-shadow: 0 3px 10px rgba(255, 107, 157, 0.4)
+ 50%
+ box-shadow: 0 6px 20px rgba(255, 107, 157, 0.6)
+
// Buttons
.btn
border: none
@@ -851,13 +937,30 @@ body
.event-text
font-size: 0.9rem
+ .birthday-icon
+ font-size: 1rem
+
+ .birthday-label
+ font-size: 0.5rem
+ padding: 1px 4px
+
.event-user
font-size: 0.8rem
+ .event-source
+ font-size: 0.7rem
+
.event-actions
justify-content: flex-end
margin-top: var(--spacing-xs)
+ .birthday-indicator
+ width: 32px
+ height: 32px
+
+ .gift-icon
+ font-size: 0.75rem
+
.btn-small
width: 32px
height: 32px
diff --git a/mobile-calendar/src/services/CalendarService.js b/mobile-calendar/src/services/CalendarService.js
index 9991870..e47cfb2 100644
--- a/mobile-calendar/src/services/CalendarService.js
+++ b/mobile-calendar/src/services/CalendarService.js
@@ -34,6 +34,15 @@ class CalendarService {
return requestUtil.delete(`${this.endpoint}/events/${eventId}`, { user });
}
+ // Get contact birthdays for a specific month
+ async getContactBirthdaysForMonth(year, month, user) {
+ let url = `${this.endpoint}/birthdays/${year}/${month}`;
+ if (user) {
+ url += `?user=${encodeURIComponent(user)}`;
+ }
+ return requestUtil.get(url);
+ }
+
// Health check
async healthCheck() {
return requestUtil.get('/api/health');
diff --git a/server/routes/calendar.js b/server/routes/calendar.js
index 6aa245f..f1ac8b7 100644
--- a/server/routes/calendar.js
+++ b/server/routes/calendar.js
@@ -149,7 +149,10 @@ router.get('/events/:year/:month', async (req, res) => {
return res.json([]);
}
- const events = await CalendarService.getFamilyEventsForMonth(users, year, month);
+ // Convert Sequelize instances to plain objects
+ const userConfigs = users.map(user => user.toJSON());
+
+ const events = await CalendarService.getFamilyEventsForMonth(userConfigs, year, month);
res.json(events);
} else {
// Get events for a specific user
@@ -168,8 +171,31 @@ router.get('/events/:year/:month', async (req, res) => {
return res.status(404).json({ error: 'User not found or inactive' });
}
- const events = await CalendarService.getEventsForMonth(userConfig, year, month);
- res.json(events);
+ // Convert Sequelize instance to plain object
+ const userConfigPlain = userConfig.toJSON();
+
+ const events = await CalendarService.getEventsForMonth(userConfigPlain, year, month);
+ const birthdayEvents = await CalendarService.getContactBirthdaysForMonth(userConfigPlain, year, month);
+
+ // Combine regular events and birthday events
+ const allEvents = [...events, ...birthdayEvents];
+
+ // 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 || '');
+ });
+
+ res.json(allEvents);
}
} catch (error) {
console.error('Error retrieving calendar events:', error.message);
@@ -205,7 +231,7 @@ router.post('/events', async (req, res) => {
return res.status(404).json({ error: 'User not found or inactive' });
}
- const event = await CalendarService.createEvent(userConfig, date, text);
+ const event = await CalendarService.createEvent(userConfig.toJSON(), date, text);
res.status(201).json(event);
} catch (error) {
console.error('Error creating calendar event:', error.message);
@@ -242,14 +268,18 @@ router.put('/events/:eventId', async (req, res) => {
return res.status(404).json({ error: 'User not found or inactive' });
}
- const event = await CalendarService.updateEvent(userConfig, eventId, date, text);
+ const event = await CalendarService.updateEvent(userConfig.toJSON(), eventId, date, text);
res.json(event);
} catch (error) {
console.error('Error updating calendar event:', error);
if (error.message === 'Event not found') {
res.status(404).json({ error: 'Event not found' });
+ } else if (error.message.includes('You can only edit events that belong to you')) {
+ res.status(403).json({ error: 'You can only edit events that belong to you' });
} else if (error.message.includes('Permission denied')) {
res.status(403).json({ error: error.message });
+ } else if (error.message.includes('CalDAV authentication failed')) {
+ res.status(401).json({ error: 'Calendar authentication failed' });
} else {
res.status(500).json({ error: 'Failed to update calendar event' });
}
@@ -269,6 +299,13 @@ router.delete('/events/:eventId', async (req, res) => {
});
}
+ // Check if this is a birthday event (birthday events should not be deletable)
+ if (eventId && eventId.includes('birthday-')) {
+ return res.status(403).json({
+ error: 'Birthday events cannot be deleted from the calendar interface'
+ });
+ }
+
const userConfig = await CalendarUser.findOne({
where: { name: user, isActive: true },
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
@@ -278,14 +315,20 @@ router.delete('/events/:eventId', async (req, res) => {
return res.status(404).json({ error: 'User not found or inactive' });
}
- await CalendarService.deleteEvent(userConfig, eventId);
+ await CalendarService.deleteEvent(userConfig.toJSON(), eventId);
res.json({ message: 'Event deleted successfully' });
} catch (error) {
console.error('Error deleting calendar event:', error);
if (error.message === 'Event not found') {
- res.status(404).json({ error: 'Event not found' });
- } else if (error.message.includes('Permission denied')) {
+ res.status(404).json({
+ error: 'Event not found. This event may belong to a different user or have already been deleted.'
+ });
+ } else if (error.message.includes('You can only delete events that belong to you')) {
+ res.status(403).json({ error: 'You can only delete events that belong to you' });
+ } else if (error.message === 'Birthday events cannot be deleted from the calendar interface') {
res.status(403).json({ error: error.message });
+ } else if (error.message.includes('Permission denied') || error.message.includes('authentication failed')) {
+ res.status(403).json({ error: 'Permission denied. You may not have access to delete this event.' });
} else {
res.status(500).json({ error: 'Failed to delete calendar event' });
}
@@ -320,4 +363,41 @@ router.post('/users/:id/test-connection', async (req, res) => {
}
});
+// Get contact birthdays for a specific month
+// GET /api/calendar/birthdays/:year/:month?user=username
+router.get('/birthdays/:year/:month', async (req, res) => {
+ try {
+ const { year, month } = req.params;
+ const { user } = req.query;
+
+ // Validate year and month
+ if (!year || !month || !/^\d{4}$/.test(year) || !/^(0[1-9]|1[0-2])$/.test(month)) {
+ return res.status(400).json({
+ error: 'Invalid year or month format. Use YYYY for year and MM for month.'
+ });
+ }
+
+ if (!user) {
+ return res.status(400).json({
+ error: 'User parameter required for birthday calendar access'
+ });
+ }
+
+ const userConfig = await CalendarUser.findOne({
+ where: { name: user, isActive: true },
+ attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
+ });
+
+ if (!userConfig) {
+ return res.status(404).json({ error: 'User not found or inactive' });
+ }
+
+ const birthdayEvents = await CalendarService.getContactBirthdaysForMonth(userConfig.toJSON(), year, month);
+ res.json(birthdayEvents);
+ } catch (error) {
+ console.error('Error retrieving contact birthdays:', error.message);
+ res.status(500).json({ error: 'Failed to retrieve contact birthdays' });
+ }
+});
+
module.exports = router;
diff --git a/server/services/CalendarService.js b/server/services/CalendarService.js
index b96a0e8..76102d6 100644
--- a/server/services/CalendarService.js
+++ b/server/services/CalendarService.js
@@ -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 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();