diff --git a/mobile-calendar/src/App.jsx b/mobile-calendar/src/App.jsx index 8e1f364..35d42a9 100644 --- a/mobile-calendar/src/App.jsx +++ b/mobile-calendar/src/App.jsx @@ -9,7 +9,9 @@ import { FaChevronLeft, FaChevronRight, FaSave, - FaTimes + FaTimes, + FaBirthdayCake, + FaGift } from 'react-icons/fa'; import calendarService from './services/CalendarService'; import './App.sass'; @@ -99,18 +101,44 @@ const App = () => { )); setEditingEvent(null); } catch (err) { - setError('Fehler beim Aktualisieren des Termins'); console.error('Error updating event:', err); + + // Show more specific error messages based on the error + if (err.message && err.message.includes('belong to you')) { + setError('Dieser Termin gehört einem anderen Benutzer und kann nicht bearbeitet werden'); + } else if (err.message && err.message.includes('not found')) { + setError('Termin nicht gefunden'); + } else if (err.message && err.message.includes('uid already exists')) { + setError('Fehler beim Aktualisieren: Termin-ID bereits vorhanden'); + } else { + setError('Fehler beim Aktualisieren des Termins'); + } } }; const handleDeleteEvent = async (eventId, user) => { try { + // Additional protection: don't allow deleting birthday events + if (eventId && eventId.includes('birthday-')) { + setError('Geburtstage können nicht über die Kalender-App gelöscht werden'); + return; + } + await calendarService.deleteEvent(eventId, user); setEvents(events.filter(event => event.id !== eventId)); } catch (err) { - setError('Fehler beim Löschen des Termins'); console.error('Error deleting event:', err); + + // Show more specific error messages based on the error + if (err.message && err.message.includes('belong to you')) { + setError('Dieser Termin gehört einem anderen Benutzer und kann nicht gelöscht werden'); + } else if (err.message && err.message.includes('not found')) { + setError('Termin nicht gefunden oder bereits gelöscht'); + } else if (err.message && err.message.includes('Birthday events')) { + setError('Geburtstage können nicht gelöscht werden'); + } else { + setError('Fehler beim Löschen des Termins'); + } } }; @@ -127,11 +155,14 @@ const App = () => { const today = new Date(); today.setHours(0, 0, 0, 0); // Reset time for comparison + // Create a date object for the event date for comparison + const eventDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + return { dayNumber: date.getDate(), dayName: date.toLocaleDateString('de-DE', { weekday: 'long' }), monthYear: date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }), - isToday: date.getTime() === today.getTime(), + isToday: eventDate.getTime() === today.getTime(), isThisMonth: date.getMonth() === currentDate.getMonth() && date.getFullYear() === currentDate.getFullYear() }; }; @@ -144,8 +175,23 @@ const App = () => { }; const groupEventsByDate = (events) => { + // First, ensure events are properly sorted by date + const sortedEvents = [...events].sort((a, b) => { + // 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 || ''); + }); + const grouped = {}; - events.forEach(event => { + sortedEvents.forEach(event => { const date = event.date; if (!grouped[date]) { grouped[date] = []; @@ -153,10 +199,12 @@ const App = () => { grouped[date].push(event); }); - // Sort dates - const sortedDates = Object.keys(grouped).sort(); + // Sort dates chronologically using string comparison for YYYY-MM-DD format + const sortedDates = Object.keys(grouped).sort((a, b) => a.localeCompare(b)); + const result = {}; sortedDates.forEach(date => { + // Events within each date are already sorted from the initial sort result[date] = grouped[date]; }); @@ -165,7 +213,12 @@ const App = () => { const groupedEvents = groupEventsByDate(events); - const getUserColor = (userName) => { + const getUserColor = (userName, eventType) => { + // Different color scheme for birthdays + if (eventType === 'birthday') { + return '#ff6b9d'; // Pink for birthdays + } + const colors = [ '#3b82f6', // blue '#10b981', // green @@ -355,9 +408,11 @@ const App = () => { onDelete={handleDeleteEvent} editingEvent={editingEvent} setEditingEvent={setEditingEvent} - userColor={getUserColor(event.user)} + userColor={getUserColor(event.user, event.type)} showUser={viewType === 'family'} users={users} + viewType={viewType} + selectedUser={selectedUser} /> ))} @@ -390,7 +445,9 @@ const CalendarEvent = ({ setEditingEvent, userColor, showUser, - users + users, + viewType, + selectedUser }) => { const [editData, setEditData] = useState({ user: event.user, @@ -399,6 +456,16 @@ const CalendarEvent = ({ }); const handleEdit = () => { + // Don't allow editing of birthday events (they come from external calendar) + if (event.type === 'birthday') { + return; + } + + // In individual view, only allow editing events that belong to the selected user + if (viewType === 'individual' && event.user !== selectedUser) { + return; + } + setEditingEvent(event.id); setEditData({ user: event.user, @@ -420,10 +487,30 @@ const CalendarEvent = ({ }); }; + const handleDelete = () => { + // Don't allow deleting of birthday events (they come from external calendar) + if (event.type === 'birthday') { + return; + } + + // In individual view, only allow deleting events that belong to the selected user + if (viewType === 'individual' && event.user !== selectedUser) { + return; + } + + // Always pass the event's original user for deletion (important for family view) + onDelete(event.id, event.user); + }; + const isEditing = editingEvent === event.id; + const isBirthday = event.type === 'birthday'; + + // Determine if this event can be edited/deleted + const canEdit = !isBirthday && (viewType === 'family' || event.user === selectedUser); + const canDelete = !isBirthday && (viewType === 'family' || event.user === selectedUser); return ( -
+
{isEditing ? (
@@ -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();