Add birthday support for mobile calendar
This commit is contained in:
@@ -9,7 +9,9 @@ import {
|
|||||||
FaChevronLeft,
|
FaChevronLeft,
|
||||||
FaChevronRight,
|
FaChevronRight,
|
||||||
FaSave,
|
FaSave,
|
||||||
FaTimes
|
FaTimes,
|
||||||
|
FaBirthdayCake,
|
||||||
|
FaGift
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import calendarService from './services/CalendarService';
|
import calendarService from './services/CalendarService';
|
||||||
import './App.sass';
|
import './App.sass';
|
||||||
@@ -99,18 +101,44 @@ const App = () => {
|
|||||||
));
|
));
|
||||||
setEditingEvent(null);
|
setEditingEvent(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Fehler beim Aktualisieren des Termins');
|
|
||||||
console.error('Error updating event:', err);
|
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) => {
|
const handleDeleteEvent = async (eventId, user) => {
|
||||||
try {
|
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);
|
await calendarService.deleteEvent(eventId, user);
|
||||||
setEvents(events.filter(event => event.id !== eventId));
|
setEvents(events.filter(event => event.id !== eventId));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Fehler beim Löschen des Termins');
|
|
||||||
console.error('Error deleting event:', err);
|
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();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0); // Reset time for comparison
|
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 {
|
return {
|
||||||
dayNumber: date.getDate(),
|
dayNumber: date.getDate(),
|
||||||
dayName: date.toLocaleDateString('de-DE', { weekday: 'long' }),
|
dayName: date.toLocaleDateString('de-DE', { weekday: 'long' }),
|
||||||
monthYear: date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }),
|
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()
|
isThisMonth: date.getMonth() === currentDate.getMonth() && date.getFullYear() === currentDate.getFullYear()
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -144,8 +175,23 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const groupEventsByDate = (events) => {
|
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 = {};
|
const grouped = {};
|
||||||
events.forEach(event => {
|
sortedEvents.forEach(event => {
|
||||||
const date = event.date;
|
const date = event.date;
|
||||||
if (!grouped[date]) {
|
if (!grouped[date]) {
|
||||||
grouped[date] = [];
|
grouped[date] = [];
|
||||||
@@ -153,10 +199,12 @@ const App = () => {
|
|||||||
grouped[date].push(event);
|
grouped[date].push(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort dates
|
// Sort dates chronologically using string comparison for YYYY-MM-DD format
|
||||||
const sortedDates = Object.keys(grouped).sort();
|
const sortedDates = Object.keys(grouped).sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
const result = {};
|
const result = {};
|
||||||
sortedDates.forEach(date => {
|
sortedDates.forEach(date => {
|
||||||
|
// Events within each date are already sorted from the initial sort
|
||||||
result[date] = grouped[date];
|
result[date] = grouped[date];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,7 +213,12 @@ const App = () => {
|
|||||||
|
|
||||||
const groupedEvents = groupEventsByDate(events);
|
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 = [
|
const colors = [
|
||||||
'#3b82f6', // blue
|
'#3b82f6', // blue
|
||||||
'#10b981', // green
|
'#10b981', // green
|
||||||
@@ -355,9 +408,11 @@ const App = () => {
|
|||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
editingEvent={editingEvent}
|
editingEvent={editingEvent}
|
||||||
setEditingEvent={setEditingEvent}
|
setEditingEvent={setEditingEvent}
|
||||||
userColor={getUserColor(event.user)}
|
userColor={getUserColor(event.user, event.type)}
|
||||||
showUser={viewType === 'family'}
|
showUser={viewType === 'family'}
|
||||||
users={users}
|
users={users}
|
||||||
|
viewType={viewType}
|
||||||
|
selectedUser={selectedUser}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -390,7 +445,9 @@ const CalendarEvent = ({
|
|||||||
setEditingEvent,
|
setEditingEvent,
|
||||||
userColor,
|
userColor,
|
||||||
showUser,
|
showUser,
|
||||||
users
|
users,
|
||||||
|
viewType,
|
||||||
|
selectedUser
|
||||||
}) => {
|
}) => {
|
||||||
const [editData, setEditData] = useState({
|
const [editData, setEditData] = useState({
|
||||||
user: event.user,
|
user: event.user,
|
||||||
@@ -399,6 +456,16 @@ const CalendarEvent = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleEdit = () => {
|
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);
|
setEditingEvent(event.id);
|
||||||
setEditData({
|
setEditData({
|
||||||
user: event.user,
|
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 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 (
|
return (
|
||||||
<div className="calendar-event" style={{ borderLeftColor: userColor }}>
|
<div className={`calendar-event ${isBirthday ? 'birthday-event' : ''}`} style={{ borderLeftColor: userColor }}>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="edit-form">
|
<div className="edit-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -469,24 +556,54 @@ const CalendarEvent = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="event-content">
|
<div className="event-content">
|
||||||
<div className="event-text">{event.text}</div>
|
<div className="event-text">
|
||||||
|
{isBirthday && (
|
||||||
|
<span className="birthday-icon">
|
||||||
|
<FaBirthdayCake />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isBirthday && event.contactName ? event.contactName : event.text}
|
||||||
|
{isBirthday && (
|
||||||
|
<span className="birthday-label">Geburtstag</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{showUser && (
|
{showUser && (
|
||||||
<div className="event-user" style={{ color: userColor }}>
|
<div className="event-user" style={{ color: userColor }}>
|
||||||
{event.user}
|
{event.user}
|
||||||
|
{isBirthday && <span className="event-source"> (Kontakte)</span>}
|
||||||
|
{!canEdit && !isBirthday && <span className="read-only-indicator"> (nur lesen)</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="event-actions">
|
{canEdit && (
|
||||||
<button className="btn btn-secondary btn-small" onClick={handleEdit}>
|
<div className="event-actions">
|
||||||
<FaEdit />
|
<button className="btn btn-secondary btn-small" onClick={handleEdit}>
|
||||||
</button>
|
<FaEdit />
|
||||||
<button
|
</button>
|
||||||
className="btn btn-danger btn-small"
|
{canDelete && (
|
||||||
onClick={() => onDelete(event.id, event.user)}
|
<button
|
||||||
>
|
className="btn btn-danger btn-small"
|
||||||
<FaTrash />
|
onClick={handleDelete}
|
||||||
</button>
|
>
|
||||||
</div>
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isBirthday && (
|
||||||
|
<div className="event-actions">
|
||||||
|
<div className="birthday-indicator">
|
||||||
|
<FaGift className="gift-icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!canEdit && !isBirthday && (
|
||||||
|
<div className="event-actions">
|
||||||
|
<div className="read-only-indicator">
|
||||||
|
<span className="read-only-text">👁️</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -477,6 +477,17 @@ body
|
|||||||
&::before
|
&::before
|
||||||
width: 8px
|
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
|
.event-content
|
||||||
flex: 1
|
flex: 1
|
||||||
min-width: 0
|
min-width: 0
|
||||||
@@ -490,6 +501,28 @@ body
|
|||||||
margin-bottom: var(--spacing-xs)
|
margin-bottom: var(--spacing-xs)
|
||||||
word-break: break-word
|
word-break: break-word
|
||||||
line-height: 1.4
|
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
|
.event-user
|
||||||
font-size: 0.875rem
|
font-size: 0.875rem
|
||||||
@@ -507,6 +540,17 @@ body
|
|||||||
background: currentColor
|
background: currentColor
|
||||||
opacity: 0.6
|
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
|
.event-actions
|
||||||
display: flex
|
display: flex
|
||||||
gap: var(--spacing-xs)
|
gap: var(--spacing-xs)
|
||||||
@@ -514,6 +558,36 @@ body
|
|||||||
position: relative
|
position: relative
|
||||||
z-index: 1
|
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
|
.edit-form
|
||||||
width: 100%
|
width: 100%
|
||||||
display: flex
|
display: flex
|
||||||
@@ -552,6 +626,18 @@ body
|
|||||||
justify-content: flex-end
|
justify-content: flex-end
|
||||||
margin-top: var(--spacing-sm)
|
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
|
// Buttons
|
||||||
.btn
|
.btn
|
||||||
border: none
|
border: none
|
||||||
@@ -851,13 +937,30 @@ body
|
|||||||
.event-text
|
.event-text
|
||||||
font-size: 0.9rem
|
font-size: 0.9rem
|
||||||
|
|
||||||
|
.birthday-icon
|
||||||
|
font-size: 1rem
|
||||||
|
|
||||||
|
.birthday-label
|
||||||
|
font-size: 0.5rem
|
||||||
|
padding: 1px 4px
|
||||||
|
|
||||||
.event-user
|
.event-user
|
||||||
font-size: 0.8rem
|
font-size: 0.8rem
|
||||||
|
|
||||||
|
.event-source
|
||||||
|
font-size: 0.7rem
|
||||||
|
|
||||||
.event-actions
|
.event-actions
|
||||||
justify-content: flex-end
|
justify-content: flex-end
|
||||||
margin-top: var(--spacing-xs)
|
margin-top: var(--spacing-xs)
|
||||||
|
|
||||||
|
.birthday-indicator
|
||||||
|
width: 32px
|
||||||
|
height: 32px
|
||||||
|
|
||||||
|
.gift-icon
|
||||||
|
font-size: 0.75rem
|
||||||
|
|
||||||
.btn-small
|
.btn-small
|
||||||
width: 32px
|
width: 32px
|
||||||
height: 32px
|
height: 32px
|
||||||
|
@@ -34,6 +34,15 @@ class CalendarService {
|
|||||||
return requestUtil.delete(`${this.endpoint}/events/${eventId}`, { user });
|
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
|
// Health check
|
||||||
async healthCheck() {
|
async healthCheck() {
|
||||||
return requestUtil.get('/api/health');
|
return requestUtil.get('/api/health');
|
||||||
|
@@ -149,7 +149,10 @@ router.get('/events/:year/:month', async (req, res) => {
|
|||||||
return res.json([]);
|
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);
|
res.json(events);
|
||||||
} else {
|
} else {
|
||||||
// Get events for a specific user
|
// 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' });
|
return res.status(404).json({ error: 'User not found or inactive' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await CalendarService.getEventsForMonth(userConfig, year, month);
|
// Convert Sequelize instance to plain object
|
||||||
res.json(events);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error retrieving calendar events:', error.message);
|
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' });
|
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);
|
res.status(201).json(event);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating calendar event:', error.message);
|
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' });
|
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);
|
res.json(event);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating calendar event:', error);
|
console.error('Error updating calendar event:', error);
|
||||||
if (error.message === 'Event not found') {
|
if (error.message === 'Event not found') {
|
||||||
res.status(404).json({ error: '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')) {
|
} else if (error.message.includes('Permission denied')) {
|
||||||
res.status(403).json({ error: error.message });
|
res.status(403).json({ error: error.message });
|
||||||
|
} else if (error.message.includes('CalDAV authentication failed')) {
|
||||||
|
res.status(401).json({ error: 'Calendar authentication failed' });
|
||||||
} else {
|
} else {
|
||||||
res.status(500).json({ error: 'Failed to update calendar event' });
|
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({
|
const userConfig = await CalendarUser.findOne({
|
||||||
where: { name: user, isActive: true },
|
where: { name: user, isActive: true },
|
||||||
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
|
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' });
|
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' });
|
res.json({ message: 'Event deleted successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting calendar event:', error);
|
console.error('Error deleting calendar event:', error);
|
||||||
if (error.message === 'Event not found') {
|
if (error.message === 'Event not found') {
|
||||||
res.status(404).json({ error: 'Event not found' });
|
res.status(404).json({
|
||||||
} else if (error.message.includes('Permission denied')) {
|
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 });
|
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 {
|
} else {
|
||||||
res.status(500).json({ error: 'Failed to delete calendar event' });
|
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;
|
module.exports = router;
|
||||||
|
@@ -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)
|
* Get calendar events for all users (family view)
|
||||||
* @param {Array} userConfigs - Array of user configurations
|
* @param {Array} userConfigs - Array of user configurations
|
||||||
@@ -96,11 +157,46 @@ class CalendarService {
|
|||||||
const userEvents = await this.getEventsForMonth(userConfig, year, month);
|
const userEvents = await this.getEventsForMonth(userConfig, year, month);
|
||||||
allEvents.push(...userEvents);
|
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;
|
return allEvents;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error retrieving family calendar events:', error);
|
console.error('Error retrieving family calendar events:', error);
|
||||||
@@ -130,7 +226,7 @@ class CalendarService {
|
|||||||
updatedAt: new Date().toISOString()
|
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);
|
const updatedEvent = await this._updateCalDAVEvent(userConfig, event);
|
||||||
|
|
||||||
return updatedEvent;
|
return updatedEvent;
|
||||||
@@ -147,11 +243,19 @@ class CalendarService {
|
|||||||
*/
|
*/
|
||||||
async deleteEvent(userConfig, eventId) {
|
async deleteEvent(userConfig, eventId) {
|
||||||
try {
|
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);
|
await this._deleteCalDAVEvent(userConfig, eventId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(`Failed to delete event ${eventId} for user ${userConfig.name}: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,12 +414,21 @@ class CalendarService {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_buildCalDAVUrl(userConfig) {
|
_buildCalDAVUrl(userConfig) {
|
||||||
// Remove trailing slash from nextcloudUrl if present
|
try {
|
||||||
const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, '');
|
// Validate userConfig
|
||||||
|
if (!userConfig || !userConfig.nextcloudUrl || !userConfig.username) {
|
||||||
// Build CalDAV principal URL - this is the standard Nextcloud CalDAV path
|
throw new Error('Invalid user configuration: missing required fields');
|
||||||
const calendarName = userConfig.calendarName || 'personal';
|
}
|
||||||
return `${baseUrl}/remote.php/dav/calendars/${userConfig.username}/${calendarName}/`;
|
|
||||||
|
// 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
|
* @private
|
||||||
*/
|
*/
|
||||||
_buildCalDAVPrincipalsUrl(userConfig) {
|
_buildCalDAVPrincipalsUrl(userConfig) {
|
||||||
const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, '');
|
try {
|
||||||
return `${baseUrl}/remote.php/dav/principals/users/${userConfig.username}/`;
|
// 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', () => {
|
res.on('end', () => {
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
try {
|
try {
|
||||||
const events = this._parseCalDAVResponse(data, userConfig);
|
const events = this._parseCalDAVResponse(data, userConfig, year, month);
|
||||||
resolve(events);
|
resolve(events);
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
reject(parseError);
|
reject(parseError);
|
||||||
@@ -489,7 +611,7 @@ class CalendarService {
|
|||||||
* Parse CalDAV response and extract events
|
* Parse CalDAV response and extract events
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_parseCalDAVResponse(xmlData, userConfig) {
|
_parseCalDAVResponse(xmlData, userConfig, requestedYear, requestedMonth) {
|
||||||
try {
|
try {
|
||||||
// This is a basic parser - in a real implementation, you'd use a proper XML parser
|
// This is a basic parser - in a real implementation, you'd use a proper XML parser
|
||||||
const events = [];
|
const events = [];
|
||||||
@@ -510,7 +632,7 @@ class CalendarService {
|
|||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const event = this._parseICSEvent(icsContent, userConfig);
|
const event = this._parseICSEvent(icsContent, userConfig, requestedYear, requestedMonth);
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
events.push(event);
|
events.push(event);
|
||||||
@@ -531,7 +653,7 @@ class CalendarService {
|
|||||||
* Parse ICS event content
|
* Parse ICS event content
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_parseICSEvent(icsContent, userConfig) {
|
_parseICSEvent(icsContent, userConfig, requestedYear, requestedMonth) {
|
||||||
try {
|
try {
|
||||||
const lines = icsContent.split('\n').map(line => line.trim()).filter(line => line);
|
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 month = parseInt(dateOnly.substring(4, 6));
|
||||||
const day = parseInt(dateOnly.substring(6, 8));
|
const day = parseInt(dateOnly.substring(6, 8));
|
||||||
|
|
||||||
// Validate the parsed date and ensure it's reasonable (not from 1981!)
|
// Validate month and day are reasonable (don't restrict year for recurring events)
|
||||||
if (year >= 2020 && year <= 2030 && month >= 1 && month <= 12 && day >= 1 && day <= 31) {
|
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
|
||||||
const parsedDate = new Date(year, month - 1, day);
|
// Create date and validate it's correct
|
||||||
if (!isNaN(parsedDate.getTime()) &&
|
const testDate = new Date(year, month - 1, day);
|
||||||
parsedDate.getFullYear() === year &&
|
if (!isNaN(testDate.getTime()) &&
|
||||||
parsedDate.getMonth() === (month - 1) &&
|
testDate.getMonth() === (month - 1) &&
|
||||||
parsedDate.getDate() === day) {
|
testDate.getDate() === day) {
|
||||||
event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
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 month = parseInt(dateOnly.substring(4, 6));
|
||||||
const day = parseInt(dateOnly.substring(6, 8));
|
const day = parseInt(dateOnly.substring(6, 8));
|
||||||
|
|
||||||
// Validate the parsed date and ensure it's reasonable (not from 1981!)
|
// Validate month and day are reasonable (don't restrict year for recurring events)
|
||||||
if (year >= 2020 && year <= 2030 && month >= 1 && month <= 12 && day >= 1 && day <= 31) {
|
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
|
||||||
const parsedDate = new Date(year, month - 1, day);
|
// Create date and validate it's correct
|
||||||
if (!isNaN(parsedDate.getTime()) &&
|
const testDate = new Date(year, month - 1, day);
|
||||||
parsedDate.getFullYear() === year &&
|
if (!isNaN(testDate.getTime()) &&
|
||||||
parsedDate.getMonth() === (month - 1) &&
|
testDate.getMonth() === (month - 1) &&
|
||||||
parsedDate.getDate() === day) {
|
testDate.getDate() === day) {
|
||||||
event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
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
|
// Only return events with required fields and valid dates
|
||||||
if (event.id && event.text && event.date) {
|
if (event.id && event.text && event.date) {
|
||||||
return event;
|
return event;
|
||||||
@@ -612,7 +779,7 @@ class CalendarService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a CalDAV event
|
* Update a calendar event
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
async _updateCalDAVEvent(userConfig, event) {
|
async _updateCalDAVEvent(userConfig, event) {
|
||||||
@@ -635,7 +802,8 @@ class CalendarService {
|
|||||||
'Authorization': `Basic ${auth}`,
|
'Authorization': `Basic ${auth}`,
|
||||||
'Content-Type': 'text/calendar; charset=utf-8',
|
'Content-Type': 'text/calendar; charset=utf-8',
|
||||||
'Content-Length': Buffer.byteLength(icsContent, 'utf8'),
|
'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'));
|
reject(new Error('CalDAV authentication failed'));
|
||||||
} else if (res.statusCode === 404) {
|
} else if (res.statusCode === 404) {
|
||||||
reject(new Error('Event not found'));
|
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 {
|
} else {
|
||||||
reject(new Error(`CalDAV event update failed with status ${res.statusCode}: ${data}`));
|
reject(new Error(`CalDAV event update failed with status ${res.statusCode}: ${data}`));
|
||||||
}
|
}
|
||||||
@@ -764,6 +939,32 @@ DTSTAMP:${now}
|
|||||||
END:VEVENT
|
END:VEVENT
|
||||||
END:VCALENDAR`;
|
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();
|
module.exports = new CalendarService();
|
||||||
|
Reference in New Issue
Block a user