feat: add alerts, invitees, attachments, busy status, and visibility to calendar events
Support extended calendar event properties across all providers: - Alerts/Reminders: VALARM for CalDAV, reminders API for Google, reminderMinutes for Outlook - Invitees/Attendees: ATTENDEE for CalDAV, attendees API for Google/Outlook - Attachments: ATTACH (URL-based) for CalDAV (Apple/Synology) - Busy Status: TRANSP for CalDAV, transparency for Google, showAs for Outlook - Visibility: CLASS for CalDAV, visibility for Google, sensitivity for Outlook Updated CalendarEventModal with new UI fields including expandable Invitees & Attachments section and reminder/status/visibility dropdowns. v1.36.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c21fd0bd88
commit
b9c05571be
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.35.3",
|
||||
"version": "1.36.0",
|
||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -39,7 +39,8 @@ export async function POST(request: NextRequest) {
|
||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const { calendarId, title, description, start, end, location, allDay, recurrence, url } = body;
|
||||
const { calendarId, title, description, start, end, location, allDay, recurrence, url,
|
||||
reminders, busyStatus, visibility, attendees, attachments } = body;
|
||||
|
||||
console.log('[API] Creating event:', { calendarId, title, start, end });
|
||||
|
||||
@ -63,6 +64,11 @@ export async function POST(request: NextRequest) {
|
||||
allDay: !!allDay,
|
||||
recurrence,
|
||||
url,
|
||||
reminders,
|
||||
busyStatus,
|
||||
visibility,
|
||||
attendees,
|
||||
attachments,
|
||||
} as any);
|
||||
|
||||
// Update cache - await to ensure it's ready before client refreshes
|
||||
@ -86,7 +92,8 @@ export async function PATCH(request: NextRequest) {
|
||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, url } = body;
|
||||
const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, url,
|
||||
reminders, busyStatus, visibility, attendees, attachments } = body;
|
||||
|
||||
console.log('[API] Updating event:', { calendarId, eventId, title });
|
||||
|
||||
@ -110,6 +117,11 @@ export async function PATCH(request: NextRequest) {
|
||||
allDay: allDay !== undefined ? !!allDay : undefined,
|
||||
recurrence,
|
||||
url,
|
||||
reminders,
|
||||
busyStatus,
|
||||
visibility,
|
||||
attendees,
|
||||
attachments,
|
||||
} as any);
|
||||
|
||||
// Update cache - await to ensure it's ready before client refreshes
|
||||
|
||||
@ -1,16 +1,45 @@
|
||||
import React, { useState, useEffect, lazy, Suspense, useRef } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip } from 'lucide-react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
|
||||
import { faServer } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const RichTextEditor = lazy(() => import('./RichTextEditor'));
|
||||
|
||||
// Reminder preset options (minutes)
|
||||
const REMINDER_OPTIONS = [
|
||||
{ label: 'None', value: -1 },
|
||||
{ label: 'At time of event', value: 0 },
|
||||
{ label: '5 minutes before', value: 5 },
|
||||
{ label: '15 minutes before', value: 15 },
|
||||
{ label: '30 minutes before', value: 30 },
|
||||
{ label: '1 hour before', value: 60 },
|
||||
{ label: '2 hours before', value: 120 },
|
||||
{ label: '12 hours before', value: 720 },
|
||||
{ label: '1 day before', value: 1440 },
|
||||
{ label: '1 week before', value: 10080 },
|
||||
];
|
||||
|
||||
const BUSY_STATUS_OPTIONS = [
|
||||
{ label: 'Busy', value: 'busy' },
|
||||
{ label: 'Free', value: 'free' },
|
||||
{ label: 'Tentative', value: 'tentative' },
|
||||
{ label: 'Out of Office', value: 'oof' },
|
||||
{ label: 'Working Elsewhere', value: 'workingElsewhere' },
|
||||
];
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ label: 'Default', value: 'default' },
|
||||
{ label: 'Public', value: 'public' },
|
||||
{ label: 'Private', value: 'private' },
|
||||
{ label: 'Confidential', value: 'confidential' },
|
||||
];
|
||||
|
||||
interface CalendarEventModalProps {
|
||||
event?: any; // Existing event if editing
|
||||
initialDate?: Date; // If creating new
|
||||
initialStartTime?: string; // If creating new from slot
|
||||
connections: any[]; // To select calendar
|
||||
event?: any;
|
||||
initialDate?: Date;
|
||||
initialStartTime?: string;
|
||||
connections: any[];
|
||||
onClose: () => void;
|
||||
onSave: (eventData: any) => Promise<void>;
|
||||
onDelete?: (eventId: string, calendarId: string) => Promise<void>;
|
||||
@ -25,7 +54,6 @@ export default function CalendarEventModal({
|
||||
onSave,
|
||||
onDelete
|
||||
}: CalendarEventModalProps) {
|
||||
// Flatten calendars from connections to get selectable options
|
||||
const availableCalendars = connections
|
||||
.flatMap(conn => (conn.calendars || []).map((cal: any) => ({
|
||||
...cal,
|
||||
@ -35,7 +63,7 @@ export default function CalendarEventModal({
|
||||
conn.provider === 'synology' ? 'Synology Calendar' :
|
||||
'Outlook Calendar'
|
||||
})))
|
||||
.filter((cal: any) => cal.editable); // Only editable calendars
|
||||
.filter((cal: any) => cal.editable);
|
||||
|
||||
const [title, setTitle] = useState(event?.title || '');
|
||||
const [description, setDescription] = useState(event?.description || '');
|
||||
@ -44,13 +72,26 @@ export default function CalendarEventModal({
|
||||
const [recurrence, setRecurrence] = useState(event?.recurrence || 'none');
|
||||
const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : ''));
|
||||
|
||||
// Date/Time State
|
||||
// If event exists, use its start/end.
|
||||
// If new, use initialDate + initialStartTime.
|
||||
// Default duration: 1 hour.
|
||||
// New fields
|
||||
const [reminders, setReminders] = useState<Array<{ method: string; minutes: number }>>(
|
||||
event?.reminders || [{ method: 'display', minutes: 15 }]
|
||||
);
|
||||
const [busyStatus, setBusyStatus] = useState<string>(event?.busyStatus || 'busy');
|
||||
const [visibility, setVisibility] = useState<string>(event?.visibility || 'default');
|
||||
const [attendees, setAttendees] = useState<Array<{ email: string; displayName?: string }>>(
|
||||
event?.attendees || []
|
||||
);
|
||||
const [newAttendeeEmail, setNewAttendeeEmail] = useState('');
|
||||
const [attachments, setAttachments] = useState<Array<{ url: string; title?: string }>>(
|
||||
event?.attachments || []
|
||||
);
|
||||
const [newAttachmentUrl, setNewAttachmentUrl] = useState('');
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(
|
||||
// Auto-expand if event has these fields set
|
||||
!!(event?.attendees?.length || event?.attachments?.length || (event?.busyStatus && event.busyStatus !== 'busy') || (event?.visibility && event.visibility !== 'default'))
|
||||
);
|
||||
|
||||
const getInitialStart = () => {
|
||||
// Support both nested (start.dateTime) and flat (startTime) event formats
|
||||
if (event?.start?.dateTime) return new Date(event.start.dateTime);
|
||||
if (event?.startTime) return new Date(event.startTime);
|
||||
if (initialDate) {
|
||||
@ -59,7 +100,6 @@ export default function CalendarEventModal({
|
||||
const [h, m] = initialStartTime.split(':').map(Number);
|
||||
d.setHours(h, m, 0, 0);
|
||||
} else {
|
||||
// Default to next hour if no time specified (though usually slot click gives time)
|
||||
const now = new Date();
|
||||
d.setHours(now.getHours() + 1, 0, 0, 0);
|
||||
}
|
||||
@ -69,11 +109,10 @@ export default function CalendarEventModal({
|
||||
};
|
||||
|
||||
const getInitialEnd = () => {
|
||||
// Support both nested (end.dateTime) and flat (endTime) event formats
|
||||
if (event?.end?.dateTime) return new Date(event.end.dateTime);
|
||||
if (event?.endTime) return new Date(event.endTime);
|
||||
const start = getInitialStart();
|
||||
return new Date(start.getTime() + 60 * 60 * 1000); // +1 hour
|
||||
return new Date(start.getTime() + 60 * 60 * 1000);
|
||||
};
|
||||
|
||||
const [startDate, setStartDate] = useState(getInitialStart());
|
||||
@ -85,7 +124,6 @@ export default function CalendarEventModal({
|
||||
const [isCalendarSelectorOpen, setIsCalendarSelectorOpen] = useState(false);
|
||||
const calendarSelectorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (calendarSelectorRef.current && !calendarSelectorRef.current.contains(event.target as Node)) {
|
||||
@ -98,6 +136,8 @@ export default function CalendarEventModal({
|
||||
|
||||
const selectedCal = availableCalendars.find((c: any) => c.id === calendarId);
|
||||
const supportsURL = selectedCal && (selectedCal.provider !== 'google' && selectedCal.provider !== 'outlook');
|
||||
const supportsAttachments = selectedCal && (selectedCal.provider === 'apple' || selectedCal.provider === 'synology');
|
||||
|
||||
const getProviderIcon = (provider: string) => {
|
||||
switch (provider) {
|
||||
case 'google': return <FontAwesomeIcon icon={faGoogle} style={{ opacity: 0.8 }} />;
|
||||
@ -125,6 +165,9 @@ export default function CalendarEventModal({
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
// Filter out "none" reminders
|
||||
const activeReminders = reminders.filter(r => r.minutes >= 0);
|
||||
|
||||
await onSave({
|
||||
id: event?.id,
|
||||
title,
|
||||
@ -135,7 +178,12 @@ export default function CalendarEventModal({
|
||||
calendarId,
|
||||
allDay,
|
||||
start: { dateTime: startDate.toISOString() },
|
||||
end: { dateTime: endDate.toISOString() }
|
||||
end: { dateTime: endDate.toISOString() },
|
||||
reminders: activeReminders.length > 0 ? activeReminders : undefined,
|
||||
busyStatus: busyStatus !== 'busy' ? busyStatus : undefined,
|
||||
visibility: visibility !== 'default' ? visibility : undefined,
|
||||
attendees: attendees.length > 0 ? attendees : undefined,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
});
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
@ -152,7 +200,7 @@ export default function CalendarEventModal({
|
||||
|
||||
if (!isDeleteConfirming) {
|
||||
setIsDeleteConfirming(true);
|
||||
setTimeout(() => setIsDeleteConfirming(false), 3000); // Reset after 3 seconds
|
||||
setTimeout(() => setIsDeleteConfirming(false), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -167,8 +215,6 @@ export default function CalendarEventModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to format date for input type="datetime-local"
|
||||
// Format: YYYY-MM-DDThh:mm
|
||||
const toLocalISOString = (date: Date) => {
|
||||
const offset = date.getTimezoneOffset() * 60000;
|
||||
const localISOTime = (new Date(date.getTime() - offset)).toISOString().slice(0, 16);
|
||||
@ -178,12 +224,60 @@ export default function CalendarEventModal({
|
||||
const handleStartDateChange = (val: string) => {
|
||||
const newStart = new Date(val);
|
||||
setStartDate(newStart);
|
||||
// Auto-adjust end date if it becomes before start
|
||||
if (endDate <= newStart) {
|
||||
setEndDate(new Date(newStart.getTime() + 60 * 60 * 1000));
|
||||
}
|
||||
};
|
||||
|
||||
const addAttendee = () => {
|
||||
const email = newAttendeeEmail.trim();
|
||||
if (!email || !email.includes('@')) return;
|
||||
if (attendees.some(a => a.email === email)) return;
|
||||
setAttendees([...attendees, { email }]);
|
||||
setNewAttendeeEmail('');
|
||||
};
|
||||
|
||||
const removeAttendee = (email: string) => {
|
||||
setAttendees(attendees.filter(a => a.email !== email));
|
||||
};
|
||||
|
||||
const addAttachment = () => {
|
||||
const attachUrl = newAttachmentUrl.trim();
|
||||
if (!attachUrl || (!attachUrl.startsWith('http://') && !attachUrl.startsWith('https://'))) return;
|
||||
if (attachments.some(a => a.url === attachUrl)) return;
|
||||
setAttachments([...attachments, { url: attachUrl }]);
|
||||
setNewAttachmentUrl('');
|
||||
};
|
||||
|
||||
const removeAttachment = (attachUrl: string) => {
|
||||
setAttachments(attachments.filter(a => a.url !== attachUrl));
|
||||
};
|
||||
|
||||
const updateReminder = (index: number, minutes: number) => {
|
||||
if (minutes === -1) {
|
||||
// Remove this reminder
|
||||
setReminders(reminders.filter((_, i) => i !== index));
|
||||
return;
|
||||
}
|
||||
const updated = [...reminders];
|
||||
updated[index] = { ...updated[index], minutes };
|
||||
setReminders(updated);
|
||||
};
|
||||
|
||||
const addReminder = () => {
|
||||
setReminders([...reminders, { method: 'display', minutes: 15 }]);
|
||||
};
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
};
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500,
|
||||
};
|
||||
const selectStyle: React.CSSProperties = {
|
||||
background: 'transparent', border: 'none', textAlign: 'right' as const,
|
||||
fontSize: '0.9rem', cursor: 'pointer', outline: 'none', color: 'var(--weekly-text)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="weekly-modal-overlay" onClick={onClose}>
|
||||
@ -192,7 +286,9 @@ export default function CalendarEventModal({
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 25px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--weekly-border)'
|
||||
border: '1px solid var(--weekly-border)',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
}}>
|
||||
{error && <div style={{ color: '#ef4444', marginBottom: '1rem', fontSize: '0.85rem', textAlign: 'center' }}>{error}</div>}
|
||||
|
||||
@ -236,9 +332,9 @@ export default function CalendarEventModal({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Calendar row: Label + Custom Selector */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 16px', position: 'relative' }}>
|
||||
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Calendar</span>
|
||||
{/* Calendar row */}
|
||||
<div style={{ ...rowStyle, padding: '4px 16px', position: 'relative' }}>
|
||||
<span style={labelStyle}>Calendar</span>
|
||||
|
||||
<div ref={calendarSelectorRef} style={{ position: 'relative', flex: 1, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
@ -246,28 +342,18 @@ export default function CalendarEventModal({
|
||||
onClick={() => !event && setIsCalendarSelectorOpen(!isCalendarSelectorOpen)}
|
||||
disabled={!!event}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
fontWeight: 500,
|
||||
cursor: event ? 'default' : 'pointer',
|
||||
outline: 'none',
|
||||
color: 'var(--weekly-text)',
|
||||
fontSize: '0.95rem',
|
||||
padding: '4px 0',
|
||||
maxWidth: '200px',
|
||||
justifyContent: 'flex-end'
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
background: 'transparent', border: 'none', fontWeight: 500,
|
||||
cursor: event ? 'default' : 'pointer', outline: 'none',
|
||||
color: 'var(--weekly-text)', fontSize: '0.95rem',
|
||||
padding: '4px 0', maxWidth: '200px', justifyContent: 'flex-end'
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{selectedCal?.summary || selectedCal?.title || 'Select Calendar'}
|
||||
</span>
|
||||
<div style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
width: '12px', height: '12px', borderRadius: '50%',
|
||||
backgroundColor: selectedCal?.backgroundColor || selectedCal?.color || '#3b82f6'
|
||||
}} />
|
||||
{!event && (isCalendarSelectorOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
|
||||
@ -275,39 +361,22 @@ export default function CalendarEventModal({
|
||||
|
||||
{isCalendarSelectorOpen && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
minWidth: '220px',
|
||||
backgroundColor: 'var(--weekly-bg-popover, #ffffff)',
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 4px 15px rgba(0,0,0,0.1)',
|
||||
border: '1px solid var(--weekly-border)',
|
||||
marginTop: '5px',
|
||||
padding: '6px',
|
||||
maxHeight: '250px',
|
||||
overflowY: 'auto'
|
||||
position: 'absolute', top: '100%', right: 0, zIndex: 100,
|
||||
minWidth: '220px', backgroundColor: 'var(--weekly-bg-popover, #ffffff)',
|
||||
borderRadius: '10px', boxShadow: '0 4px 15px rgba(0,0,0,0.1)',
|
||||
border: '1px solid var(--weekly-border)', marginTop: '5px',
|
||||
padding: '6px', maxHeight: '250px', overflowY: 'auto'
|
||||
}}>
|
||||
{availableCalendars.map((cal: any) => (
|
||||
<div
|
||||
key={cal.id}
|
||||
onClick={() => {
|
||||
setCalendarId(cal.id);
|
||||
setIsCalendarSelectorOpen(false);
|
||||
}}
|
||||
onClick={() => { setCalendarId(cal.id); setIsCalendarSelectorOpen(false); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
color: 'var(--weekly-text)',
|
||||
display: 'flex', alignItems: 'center', gap: '10px',
|
||||
padding: '8px 12px', borderRadius: '6px', cursor: 'pointer',
|
||||
fontSize: '0.9rem', color: 'var(--weekly-text)',
|
||||
backgroundColor: calendarId === cal.id ? 'var(--weekly-selection, rgba(59, 130, 246, 0.1))' : 'transparent',
|
||||
transition: 'background 0.2s',
|
||||
textAlign: 'left'
|
||||
transition: 'background 0.2s', textAlign: 'left'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--weekly-hover, rgba(0,0,0,0.05))'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = calendarId === cal.id ? 'var(--weekly-selection, rgba(59, 130, 246, 0.1))' : 'transparent'}
|
||||
@ -327,8 +396,8 @@ export default function CalendarEventModal({
|
||||
</div>
|
||||
|
||||
{/* All Day row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 16px' }}>
|
||||
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>All Day</span>
|
||||
<div style={{ ...rowStyle, padding: '4px 16px' }}>
|
||||
<span style={labelStyle}>All Day</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allDay}
|
||||
@ -339,35 +408,29 @@ export default function CalendarEventModal({
|
||||
|
||||
{/* Date/Time rows */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '0 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Starts</span>
|
||||
<div style={rowStyle}>
|
||||
<span style={labelStyle}>Starts</span>
|
||||
<input
|
||||
type={allDay ? "date" : "datetime-local"}
|
||||
value={allDay ? startDate.toISOString().split('T')[0] : toLocalISOString(startDate)}
|
||||
onChange={e => handleStartDateChange(e.target.value)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px', border: 'none', borderRadius: '6px',
|
||||
background: 'var(--weekly-bg-secondary, #f3f4f6)',
|
||||
fontSize: '0.9rem',
|
||||
textAlign: 'center'
|
||||
fontSize: '0.9rem', textAlign: 'center'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Ends</span>
|
||||
<div style={rowStyle}>
|
||||
<span style={labelStyle}>Ends</span>
|
||||
<input
|
||||
type={allDay ? "date" : "datetime-local"}
|
||||
value={allDay ? endDate.toISOString().split('T')[0] : toLocalISOString(endDate)}
|
||||
onChange={e => setEndDate(new Date(e.target.value))}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px', border: 'none', borderRadius: '6px',
|
||||
background: 'var(--weekly-bg-secondary, #f3f4f6)',
|
||||
fontSize: '0.9rem',
|
||||
textAlign: 'center'
|
||||
fontSize: '0.9rem', textAlign: 'center'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -376,21 +439,9 @@ export default function CalendarEventModal({
|
||||
{/* Meta Fields Group */}
|
||||
<div style={{ padding: '8px 16px', display: 'flex', flexDirection: 'column', gap: '12px', marginTop: '4px' }}>
|
||||
{/* Recurrence */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Repeat</span>
|
||||
<select
|
||||
value={recurrence}
|
||||
onChange={e => setRecurrence(e.target.value)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
textAlign: 'right',
|
||||
fontSize: '0.9rem',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
color: 'var(--weekly-text)'
|
||||
}}
|
||||
>
|
||||
<div style={rowStyle}>
|
||||
<span style={labelStyle}>Repeat</span>
|
||||
<select value={recurrence} onChange={e => setRecurrence(e.target.value)} style={selectStyle}>
|
||||
<option value="none">Never</option>
|
||||
<option value="daily">Every Day</option>
|
||||
<option value="weekly">Every Week</option>
|
||||
@ -399,6 +450,62 @@ export default function CalendarEventModal({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Reminders */}
|
||||
<div>
|
||||
<div style={{ ...rowStyle, marginBottom: reminders.length > 0 ? '6px' : 0 }}>
|
||||
<span style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Bell size={14} /> Alert
|
||||
</span>
|
||||
{reminders.length === 0 && (
|
||||
<button onClick={addReminder} style={{ ...selectStyle, color: '#3b82f6', cursor: 'pointer', background: 'none', border: 'none' }}>
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{reminders.map((reminder, idx) => (
|
||||
<div key={idx} style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '8px', marginBottom: '4px' }}>
|
||||
<select
|
||||
value={reminder.minutes}
|
||||
onChange={e => updateReminder(idx, parseInt(e.target.value))}
|
||||
style={selectStyle}
|
||||
>
|
||||
{REMINDER_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={() => setReminders(reminders.filter((_, i) => i !== idx))}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)' }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{reminders.length > 0 && reminders.length < 2 && (
|
||||
<button onClick={addReminder} style={{ fontSize: '0.85rem', color: '#3b82f6', background: 'none', border: 'none', cursor: 'pointer', padding: '2px 0' }}>
|
||||
+ Add another alert
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Busy Status */}
|
||||
<div style={rowStyle}>
|
||||
<span style={labelStyle}>Status</span>
|
||||
<select value={busyStatus} onChange={e => setBusyStatus(e.target.value)} style={selectStyle}>
|
||||
{BUSY_STATUS_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div style={rowStyle}>
|
||||
<span style={labelStyle}>Visibility</span>
|
||||
<select value={visibility} onChange={e => setVisibility(e.target.value)} style={selectStyle}>
|
||||
{VISIBILITY_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* URL (Conditional) */}
|
||||
{supportsURL && (
|
||||
<input
|
||||
@ -407,17 +514,118 @@ export default function CalendarEventModal({
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder="URL"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px 0',
|
||||
border: 'none',
|
||||
width: '100%', padding: '6px 0', border: 'none',
|
||||
borderBottom: '1px solid var(--weekly-border)',
|
||||
background: 'transparent',
|
||||
fontSize: '0.9rem',
|
||||
outline: 'none'
|
||||
background: 'transparent', fontSize: '0.9rem', outline: 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Expandable section: Attendees & Attachments */}
|
||||
<button
|
||||
onClick={() => setShowMoreOptions(!showMoreOptions)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: '#3b82f6', fontSize: '0.9rem', padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
{showMoreOptions ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
Invitees & Attachments
|
||||
</button>
|
||||
|
||||
{showMoreOptions && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{/* Attendees */}
|
||||
<div>
|
||||
<span style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' }}>
|
||||
<Users size={14} /> Invitees
|
||||
</span>
|
||||
{attendees.map((att, idx) => (
|
||||
<div key={idx} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '4px 8px', marginBottom: '4px',
|
||||
background: 'var(--weekly-bg-secondary, #f3f4f6)', borderRadius: '6px',
|
||||
fontSize: '0.85rem',
|
||||
}}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{att.displayName ? `${att.displayName} (${att.email})` : att.email}
|
||||
</span>
|
||||
<button onClick={() => removeAttendee(att.email)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', flexShrink: 0 }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="email"
|
||||
value={newAttendeeEmail}
|
||||
onChange={e => setNewAttendeeEmail(e.target.value)}
|
||||
placeholder="Add email address"
|
||||
onKeyDown={e => e.key === 'Enter' && addAttendee()}
|
||||
style={{
|
||||
flex: 1, padding: '6px 8px', border: 'none',
|
||||
borderBottom: '1px solid var(--weekly-border)',
|
||||
background: 'transparent', fontSize: '0.85rem', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button onClick={addAttendee} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: '#3b82f6', padding: '4px',
|
||||
}}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments (URL-based, Apple/Synology only) */}
|
||||
{supportsAttachments && (
|
||||
<div>
|
||||
<span style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' }}>
|
||||
<Paperclip size={14} /> Attachments
|
||||
</span>
|
||||
{attachments.map((att, idx) => (
|
||||
<div key={idx} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '4px 8px', marginBottom: '4px',
|
||||
background: 'var(--weekly-bg-secondary, #f3f4f6)', borderRadius: '6px',
|
||||
fontSize: '0.85rem',
|
||||
}}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{att.title || att.url}
|
||||
</span>
|
||||
<button onClick={() => removeAttachment(att.url)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', flexShrink: 0 }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="url"
|
||||
value={newAttachmentUrl}
|
||||
onChange={e => setNewAttachmentUrl(e.target.value)}
|
||||
placeholder="Add attachment URL"
|
||||
onKeyDown={e => e.key === 'Enter' && addAttachment()}
|
||||
style={{
|
||||
flex: 1, padding: '6px 8px', border: 'none',
|
||||
borderBottom: '1px solid var(--weekly-border)',
|
||||
background: 'transparent', fontSize: '0.85rem', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button onClick={addAttachment} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: '#3b82f6', padding: '4px',
|
||||
}}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Suspense fallback={
|
||||
@ -447,15 +655,9 @@ export default function CalendarEventModal({
|
||||
onClick={handleDelete}
|
||||
disabled={isSaving || isDeleting}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'none',
|
||||
color: '#ef4444',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
padding: '8px 12px', background: 'none', color: '#ef4444',
|
||||
border: 'none', borderRadius: '6px', fontSize: '0.9rem',
|
||||
fontWeight: 500, cursor: 'pointer', transition: 'all 0.2s',
|
||||
opacity: isSaving || isDeleting ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
@ -480,14 +682,9 @@ export default function CalendarEventModal({
|
||||
onClick={handleSubmit}
|
||||
disabled={isSaving || isDeleting}
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: 600,
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '8px 20px', borderRadius: '8px', fontSize: '0.95rem',
|
||||
fontWeight: 600, backgroundColor: '#3b82f6', color: 'white',
|
||||
border: 'none', cursor: 'pointer',
|
||||
opacity: isSaving || isDeleting ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
|
||||
@ -14,6 +14,11 @@ export interface AppleCalendarEvent {
|
||||
url?: string;
|
||||
recurringEventId?: string;
|
||||
isRecurring?: boolean;
|
||||
reminders?: Array<{ method: string; minutes: number }>;
|
||||
attendees?: Array<{ email: string; displayName?: string; responseStatus?: string }>;
|
||||
attachments?: Array<{ url: string; title?: string }>;
|
||||
busyStatus?: string;
|
||||
visibility?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -26,6 +31,122 @@ function formatDateToLocalISO(date: Date): string {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert reminder minutes to iCalendar TRIGGER duration string (e.g. -PT15M, -PT1H, -P1D, -P1W)
|
||||
*/
|
||||
function reminderMinutesToDuration(minutes: number): string {
|
||||
if (minutes === 0) return 'PT0S'; // At time of event
|
||||
const prefix = '-'; // before event
|
||||
if (minutes % 10080 === 0) return `${prefix}P${minutes / 10080}W`;
|
||||
if (minutes % 1440 === 0) return `${prefix}P${minutes / 1440}D`;
|
||||
if (minutes % 60 === 0) return `${prefix}PT${minutes / 60}H`;
|
||||
return `${prefix}PT${minutes}M`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract extended properties (reminders, attendees, attachments, busy status, visibility) from a VEVENT component
|
||||
*/
|
||||
function extractExtendedProps(vevent: any): Pick<AppleCalendarEvent, 'reminders' | 'attendees' | 'attachments' | 'busyStatus' | 'visibility'> {
|
||||
const result: Pick<AppleCalendarEvent, 'reminders' | 'attendees' | 'attachments' | 'busyStatus' | 'visibility'> = {};
|
||||
|
||||
// Extract VALARM components
|
||||
const valarms = vevent.getAllSubcomponents('valarm');
|
||||
if (valarms && valarms.length > 0) {
|
||||
result.reminders = valarms.map((valarm: any) => {
|
||||
const action = valarm.getFirstPropertyValue('action') || 'DISPLAY';
|
||||
const trigger = valarm.getFirstProperty('trigger');
|
||||
let minutes = 15; // default
|
||||
if (trigger) {
|
||||
const triggerVal = trigger.getFirstValue();
|
||||
if (triggerVal && typeof triggerVal.toSeconds === 'function') {
|
||||
// ICAL.Duration - convert to minutes (negative = before event)
|
||||
minutes = Math.abs(Math.round(triggerVal.toSeconds() / 60));
|
||||
} else if (typeof triggerVal === 'string') {
|
||||
// Parse ISO duration like -PT15M
|
||||
const match = triggerVal.match(/^-?PT?(\d+)([MHDS])/i);
|
||||
if (match) {
|
||||
const val = parseInt(match[1]);
|
||||
switch (match[2].toUpperCase()) {
|
||||
case 'M': minutes = val; break;
|
||||
case 'H': minutes = val * 60; break;
|
||||
case 'D': minutes = val * 1440; break;
|
||||
case 'S': minutes = Math.round(val / 60); break;
|
||||
}
|
||||
}
|
||||
// Handle -P1W (1 week)
|
||||
const weekMatch = triggerVal.match(/^-?P(\d+)W/i);
|
||||
if (weekMatch) minutes = parseInt(weekMatch[1]) * 10080;
|
||||
// Handle -P1D (1 day)
|
||||
const dayMatch = triggerVal.match(/^-?P(\d+)D/i);
|
||||
if (dayMatch) minutes = parseInt(dayMatch[1]) * 1440;
|
||||
}
|
||||
}
|
||||
return {
|
||||
method: action.toString().toLowerCase() === 'email' ? 'email' : 'display',
|
||||
minutes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Extract ATTENDEE properties
|
||||
const attendeeProps = vevent.getAllProperties('attendee');
|
||||
if (attendeeProps && attendeeProps.length > 0) {
|
||||
result.attendees = attendeeProps.map((prop: any) => {
|
||||
const val = prop.getFirstValue() || '';
|
||||
const email = val.replace(/^mailto:/i, '');
|
||||
const cn = prop.getParameter('cn');
|
||||
const partstat = prop.getParameter('partstat');
|
||||
const statusMap: Record<string, string> = {
|
||||
'ACCEPTED': 'accepted',
|
||||
'DECLINED': 'declined',
|
||||
'TENTATIVE': 'tentative',
|
||||
'NEEDS-ACTION': 'needsAction',
|
||||
};
|
||||
return {
|
||||
email,
|
||||
displayName: cn || undefined,
|
||||
responseStatus: statusMap[partstat?.toUpperCase()] || 'needsAction',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Extract ATTACH properties (URL-based)
|
||||
const attachProps = vevent.getAllProperties('attach');
|
||||
if (attachProps && attachProps.length > 0) {
|
||||
result.attachments = attachProps
|
||||
.map((prop: any) => {
|
||||
const val = prop.getFirstValue();
|
||||
if (typeof val === 'string' && (val.startsWith('http://') || val.startsWith('https://'))) {
|
||||
const fmttype = prop.getParameter('fmttype');
|
||||
const filename = prop.getParameter('filename');
|
||||
return { url: val, title: filename || undefined };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as Array<{ url: string; title?: string }>;
|
||||
if (result.attachments.length === 0) delete result.attachments;
|
||||
}
|
||||
|
||||
// Extract TRANSP (busy status)
|
||||
const transp = vevent.getFirstPropertyValue('transp');
|
||||
if (transp) {
|
||||
result.busyStatus = transp.toString().toUpperCase() === 'TRANSPARENT' ? 'free' : 'busy';
|
||||
}
|
||||
|
||||
// Extract CLASS (visibility)
|
||||
const cls = vevent.getFirstPropertyValue('class');
|
||||
if (cls) {
|
||||
const clsMap: Record<string, string> = {
|
||||
'PUBLIC': 'public',
|
||||
'PRIVATE': 'private',
|
||||
'CONFIDENTIAL': 'confidential',
|
||||
};
|
||||
result.visibility = (clsMap[cls.toString().toUpperCase()] || 'default') as any;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface AppleCalendar {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -182,6 +303,7 @@ export const getUpcomingEvents = async (
|
||||
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||
recurringEventId: exEvent.uid,
|
||||
isRecurring: true,
|
||||
...extractExtendedProps(exVevent),
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -217,6 +339,7 @@ export const getUpcomingEvents = async (
|
||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||
recurringEventId: event.uid,
|
||||
isRecurring: true,
|
||||
...extractExtendedProps(vevent),
|
||||
});
|
||||
}
|
||||
} catch (expandErr: any) {
|
||||
@ -238,6 +361,7 @@ export const getUpcomingEvents = async (
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||
...extractExtendedProps(vevent),
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -268,6 +392,11 @@ export const createEvent = async (
|
||||
recurrence?: string;
|
||||
start: { dateTime?: string; date?: string };
|
||||
end: { dateTime?: string; date?: string };
|
||||
reminders?: Array<{ method: string; minutes: number }>;
|
||||
attendees?: Array<{ email: string; displayName?: string }>;
|
||||
attachments?: Array<{ url: string; title?: string }>;
|
||||
busyStatus?: string;
|
||||
visibility?: string;
|
||||
}
|
||||
): Promise<AppleCalendarEvent> => {
|
||||
try {
|
||||
@ -330,6 +459,44 @@ export const createEvent = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Generate VALARM blocks for reminders
|
||||
let valarmLines = '';
|
||||
if (eventData.reminders && eventData.reminders.length > 0) {
|
||||
for (const reminder of eventData.reminders) {
|
||||
const action = reminder.method === 'email' ? 'EMAIL' : 'DISPLAY';
|
||||
const dur = reminderMinutesToDuration(reminder.minutes);
|
||||
valarmLines += `BEGIN:VALARM\r\nACTION:${action}\r\nTRIGGER:${dur}\r\n`;
|
||||
if (action === 'DISPLAY') valarmLines += `DESCRIPTION:Reminder\r\n`;
|
||||
valarmLines += `END:VALARM\r\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate ATTENDEE lines
|
||||
let attendeeLines = '';
|
||||
if (eventData.attendees && eventData.attendees.length > 0) {
|
||||
for (const att of eventData.attendees) {
|
||||
const cn = att.displayName ? `;CN=${att.displayName}` : '';
|
||||
attendeeLines += `ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION${cn}:mailto:${att.email}\r\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate ATTACH lines (URL-based)
|
||||
let attachLines = '';
|
||||
if (eventData.attachments && eventData.attachments.length > 0) {
|
||||
for (const att of eventData.attachments) {
|
||||
attachLines += `ATTACH:${att.url}\r\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// TRANSP (busy status)
|
||||
const transpLine = eventData.busyStatus === 'free' ? 'TRANSP:TRANSPARENT\r\n' : eventData.busyStatus ? 'TRANSP:OPAQUE\r\n' : '';
|
||||
|
||||
// CLASS (visibility)
|
||||
let classLine = '';
|
||||
if (eventData.visibility && eventData.visibility !== 'default') {
|
||||
classLine = `CLASS:${eventData.visibility.toUpperCase()}\r\n`;
|
||||
}
|
||||
|
||||
const iCalString = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//My Weekly ToDo List//EN
|
||||
@ -339,7 +506,7 @@ DTSTAMP:${dtStamp}
|
||||
DTSTART${dtStartParam}:${dtStart}
|
||||
DTEND${dtEndParam}:${dtEnd}
|
||||
SUMMARY:${eventData.title}
|
||||
${description}${location}${url}${rruleLine}END:VEVENT
|
||||
${description}${location}${url}${rruleLine}${transpLine}${classLine}${attendeeLines}${attachLines}${valarmLines}END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
|
||||
console.log('[APPLE CALENDAR] Creating event with iCal:', iCalString);
|
||||
@ -382,6 +549,11 @@ export const updateEvent = async (
|
||||
url?: string;
|
||||
start?: { dateTime?: string; date?: string };
|
||||
end?: { dateTime?: string; date?: string };
|
||||
reminders?: Array<{ method: string; minutes: number }>;
|
||||
attendees?: Array<{ email: string; displayName?: string }>;
|
||||
attachments?: Array<{ url: string; title?: string }>;
|
||||
busyStatus?: string;
|
||||
visibility?: string;
|
||||
}
|
||||
): Promise<AppleCalendarEvent> => {
|
||||
try {
|
||||
@ -473,6 +645,65 @@ export const updateEvent = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Update reminders (VALARM)
|
||||
if (eventData.reminders !== undefined) {
|
||||
// Remove existing VALARMs
|
||||
const existingAlarms = vevent.getAllSubcomponents('valarm');
|
||||
existingAlarms.forEach((a: any) => vevent.removeSubcomponent(a));
|
||||
// Add new ones
|
||||
for (const reminder of eventData.reminders) {
|
||||
const valarm = new ICAL.Component('valarm');
|
||||
valarm.addPropertyWithValue('action', reminder.method === 'email' ? 'EMAIL' : 'DISPLAY');
|
||||
const dur = ICAL.Duration.fromString(reminderMinutesToDuration(reminder.minutes));
|
||||
valarm.addPropertyWithValue('trigger', dur);
|
||||
if (reminder.method !== 'email') {
|
||||
valarm.addPropertyWithValue('description', 'Reminder');
|
||||
}
|
||||
vevent.addSubcomponent(valarm);
|
||||
}
|
||||
}
|
||||
|
||||
// Update attendees
|
||||
if (eventData.attendees !== undefined) {
|
||||
// Remove existing ATTENDEEs
|
||||
vevent.removeAllProperties('attendee');
|
||||
for (const att of eventData.attendees) {
|
||||
const prop = new ICAL.Property('attendee');
|
||||
prop.setValue(`mailto:${att.email}`);
|
||||
prop.setParameter('cutype', 'INDIVIDUAL');
|
||||
prop.setParameter('role', 'REQ-PARTICIPANT');
|
||||
prop.setParameter('partstat', 'NEEDS-ACTION');
|
||||
if (att.displayName) prop.setParameter('cn', att.displayName);
|
||||
vevent.addProperty(prop);
|
||||
}
|
||||
}
|
||||
|
||||
// Update attachments
|
||||
if (eventData.attachments !== undefined) {
|
||||
vevent.removeAllProperties('attach');
|
||||
for (const att of eventData.attachments) {
|
||||
vevent.addPropertyWithValue('attach', att.url);
|
||||
}
|
||||
}
|
||||
|
||||
// Update TRANSP (busy status)
|
||||
if (eventData.busyStatus !== undefined) {
|
||||
if (eventData.busyStatus === 'free') {
|
||||
vevent.updatePropertyWithValue('transp', 'TRANSPARENT');
|
||||
} else if (eventData.busyStatus) {
|
||||
vevent.updatePropertyWithValue('transp', 'OPAQUE');
|
||||
}
|
||||
}
|
||||
|
||||
// Update CLASS (visibility)
|
||||
if (eventData.visibility !== undefined) {
|
||||
if (eventData.visibility && eventData.visibility !== 'default') {
|
||||
vevent.updatePropertyWithValue('class', eventData.visibility.toUpperCase());
|
||||
} else {
|
||||
vevent.removeProperty('class');
|
||||
}
|
||||
}
|
||||
|
||||
// Bump sequence
|
||||
event.sequence = (event.sequence || 0) + 1;
|
||||
if (vevent) {
|
||||
|
||||
@ -6,6 +6,25 @@ import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export interface EventReminder {
|
||||
method: 'popup' | 'email' | 'display'; // display = VALARM DISPLAY, popup = Google popup, email = email reminder
|
||||
minutes: number; // minutes before event
|
||||
}
|
||||
|
||||
export interface EventAttendee {
|
||||
email: string;
|
||||
displayName?: string;
|
||||
responseStatus?: 'needsAction' | 'accepted' | 'declined' | 'tentative';
|
||||
}
|
||||
|
||||
export interface EventAttachment {
|
||||
url: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export type BusyStatus = 'free' | 'tentative' | 'busy' | 'oof' | 'workingElsewhere';
|
||||
export type EventVisibility = 'default' | 'public' | 'private' | 'confidential';
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -28,6 +47,11 @@ export interface CalendarEvent {
|
||||
calendarTitle: string;
|
||||
backgroundColor?: string;
|
||||
allDay?: boolean;
|
||||
reminders?: EventReminder[];
|
||||
busyStatus?: BusyStatus;
|
||||
visibility?: EventVisibility;
|
||||
attendees?: EventAttendee[];
|
||||
attachments?: EventAttachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -295,9 +319,17 @@ export const getCalendarEvents = async (
|
||||
|
||||
events = events.concat(calendarEvents.map((event: any) => {
|
||||
const eventColor = event.colorId ? getGoogleEventColor(event.colorId) : calendarData?.backgroundColor;
|
||||
// Map Google reminders to our format
|
||||
const reminders: EventReminder[] | undefined = event.reminders?.overrides?.map((r: any) => ({
|
||||
method: r.method === 'email' ? 'email' : 'popup',
|
||||
minutes: r.minutes,
|
||||
})) || undefined;
|
||||
// Map Google transparency to busyStatus
|
||||
const busyStatus: BusyStatus | undefined = event.transparency === 'transparent' ? 'free'
|
||||
: event.transparency === 'opaque' ? 'busy' : undefined;
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.summary || '(No Title)', // Map summary to title
|
||||
title: event.summary || '(No Title)',
|
||||
description: event.description,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
@ -305,7 +337,15 @@ export const getCalendarEvents = async (
|
||||
source: 'google' as const,
|
||||
calendarId,
|
||||
calendarTitle: calendarData?.summary || calendarData?.title || 'Google Calendar',
|
||||
backgroundColor: eventColor
|
||||
backgroundColor: eventColor,
|
||||
reminders,
|
||||
busyStatus,
|
||||
visibility: event.visibility as EventVisibility || undefined,
|
||||
attendees: event.attendees?.map((a: any) => ({
|
||||
email: a.email,
|
||||
displayName: a.displayName,
|
||||
responseStatus: a.responseStatus,
|
||||
})) as EventAttendee[] || undefined,
|
||||
};
|
||||
}));
|
||||
} catch (calError) {
|
||||
@ -346,7 +386,6 @@ export const getCalendarEvents = async (
|
||||
console.log(`[CALENDAR] Fetched ${calendarEvents.length} events from calendar ${calendarId}`);
|
||||
|
||||
events = events.concat(calendarEvents.map((event: any) => {
|
||||
// Date-only strings (YYYY-MM-DD) indicate all-day events
|
||||
const isDateOnly = (s: string) => s && !s.includes('T');
|
||||
const startIsAllDay = isDateOnly(event.startDate);
|
||||
return {
|
||||
@ -368,7 +407,12 @@ export const getCalendarEvents = async (
|
||||
source: 'apple' as const,
|
||||
calendarId,
|
||||
calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar',
|
||||
backgroundColor: calendars.find(c => c.id === calendarId)?.color || '#FF3B30'
|
||||
backgroundColor: calendars.find(c => c.id === calendarId)?.color || '#FF3B30',
|
||||
reminders: event.reminders as EventReminder[] || undefined,
|
||||
busyStatus: event.busyStatus as BusyStatus || undefined,
|
||||
visibility: event.visibility as EventVisibility || undefined,
|
||||
attendees: event.attendees as EventAttendee[] || undefined,
|
||||
attachments: event.attachments as EventAttachment[] || undefined,
|
||||
};
|
||||
}));
|
||||
}
|
||||
@ -440,7 +484,12 @@ export const getCalendarEvents = async (
|
||||
source: 'synology' as const,
|
||||
calendarId,
|
||||
calendarTitle: calendarData?.title || 'Synology Calendar',
|
||||
backgroundColor: calendarData?.color || '#1b85ff'
|
||||
backgroundColor: calendarData?.color || '#1b85ff',
|
||||
reminders: event.reminders as EventReminder[] || undefined,
|
||||
busyStatus: event.busyStatus as BusyStatus || undefined,
|
||||
visibility: event.visibility as EventVisibility || undefined,
|
||||
attendees: event.attendees as EventAttendee[] || undefined,
|
||||
attachments: event.attachments as EventAttachment[] || undefined,
|
||||
};
|
||||
}));
|
||||
} catch (calError) {
|
||||
@ -495,7 +544,11 @@ export const getCalendarEvents = async (
|
||||
source: 'outlook' as const,
|
||||
calendarId,
|
||||
calendarTitle: calendarData?.title || 'Outlook Calendar',
|
||||
backgroundColor: '#0078d4' // Outlook Blue
|
||||
backgroundColor: '#0078d4',
|
||||
reminders: event.reminders as EventReminder[] || undefined,
|
||||
busyStatus: event.busyStatus as BusyStatus || undefined,
|
||||
visibility: event.visibility as EventVisibility || undefined,
|
||||
attendees: event.attendees as EventAttendee[] || undefined,
|
||||
})));
|
||||
|
||||
} catch (calError) {
|
||||
@ -641,6 +694,12 @@ export const createCalendarEvent = async (
|
||||
...(rrule ? { recurrence: [rrule] } : {}),
|
||||
...(event.allDay ? { allDay: true } : {}),
|
||||
...(event.url ? { source: { url: event.url, title: event.url } } : {}),
|
||||
...(event.reminders?.length ? {
|
||||
reminders: { useDefault: false, overrides: event.reminders.map(r => ({ method: r.method === 'email' ? 'email' : 'popup', minutes: r.minutes })) }
|
||||
} : {}),
|
||||
...(event.busyStatus ? { transparency: event.busyStatus === 'free' ? 'transparent' : 'opaque' } : {}),
|
||||
...(event.visibility ? { visibility: event.visibility } : {}),
|
||||
...(event.attendees?.length ? { attendees: event.attendees.map(a => ({ email: a.email, displayName: a.displayName })) } : {}),
|
||||
};
|
||||
|
||||
const createdEvent = await import('./google-calendar').then(m =>
|
||||
@ -678,6 +737,10 @@ export const createCalendarEvent = async (
|
||||
location: event.location,
|
||||
allDay: event.allDay,
|
||||
recurrence: toOutlookRecurrence(event.recurrence, startDate),
|
||||
reminders: event.reminders,
|
||||
busyStatus: event.busyStatus,
|
||||
visibility: event.visibility,
|
||||
attendees: event.attendees,
|
||||
});
|
||||
|
||||
return {
|
||||
@ -731,7 +794,12 @@ export const createCalendarEvent = async (
|
||||
url: event.url,
|
||||
recurrence: event.recurrence,
|
||||
start,
|
||||
end
|
||||
end,
|
||||
reminders: event.reminders,
|
||||
attendees: event.attendees,
|
||||
attachments: event.attachments,
|
||||
busyStatus: event.busyStatus,
|
||||
visibility: event.visibility,
|
||||
})
|
||||
);
|
||||
|
||||
@ -762,7 +830,12 @@ export const createCalendarEvent = async (
|
||||
url: event.url,
|
||||
recurrence: event.recurrence,
|
||||
start: event.start!,
|
||||
end: event.end!
|
||||
end: event.end!,
|
||||
reminders: event.reminders,
|
||||
attendees: event.attendees,
|
||||
attachments: event.attachments,
|
||||
busyStatus: event.busyStatus,
|
||||
visibility: event.visibility,
|
||||
})
|
||||
);
|
||||
|
||||
@ -816,6 +889,12 @@ export const updateCalendarEvent = async (
|
||||
if (event.location !== undefined) googleEvent.location = event.location;
|
||||
if (rrule) googleEvent.recurrence = [rrule];
|
||||
if (event.url) googleEvent.source = { url: event.url, title: event.url };
|
||||
if (event.reminders?.length) {
|
||||
googleEvent.reminders = { useDefault: false, overrides: event.reminders.map(r => ({ method: r.method === 'email' ? 'email' : 'popup', minutes: r.minutes })) };
|
||||
}
|
||||
if (event.busyStatus) googleEvent.transparency = event.busyStatus === 'free' ? 'transparent' : 'opaque';
|
||||
if (event.visibility) googleEvent.visibility = event.visibility;
|
||||
if (event.attendees) googleEvent.attendees = event.attendees.map(a => ({ email: a.email, displayName: a.displayName }));
|
||||
|
||||
// Google adds _date suffix for instances. Editing base series only.
|
||||
const baseEventId = eventId.split('_')[0];
|
||||
@ -855,6 +934,10 @@ export const updateCalendarEvent = async (
|
||||
location: event.location,
|
||||
allDay: event.allDay,
|
||||
recurrence: toOutlookRecurrence(event.recurrence, startDate),
|
||||
reminders: event.reminders,
|
||||
busyStatus: event.busyStatus,
|
||||
visibility: event.visibility,
|
||||
attendees: event.attendees,
|
||||
});
|
||||
|
||||
return {
|
||||
@ -897,7 +980,12 @@ export const updateCalendarEvent = async (
|
||||
location: event.location,
|
||||
url: event.url,
|
||||
start: event.start,
|
||||
end: event.end
|
||||
end: event.end,
|
||||
reminders: event.reminders,
|
||||
attendees: event.attendees,
|
||||
attachments: event.attachments,
|
||||
busyStatus: event.busyStatus,
|
||||
visibility: event.visibility,
|
||||
})
|
||||
);
|
||||
|
||||
@ -926,7 +1014,12 @@ export const updateCalendarEvent = async (
|
||||
location: event.location,
|
||||
url: event.url,
|
||||
start: event.start,
|
||||
end: event.end
|
||||
end: event.end,
|
||||
reminders: event.reminders,
|
||||
attendees: event.attendees,
|
||||
attachments: event.attachments,
|
||||
busyStatus: event.busyStatus,
|
||||
visibility: event.visibility,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@ -16,9 +16,16 @@ export interface GoogleCalendarEvent {
|
||||
attendees?: Array<{
|
||||
email: string;
|
||||
displayName?: string;
|
||||
responseStatus?: string;
|
||||
}>;
|
||||
location?: string;
|
||||
colorId?: string;
|
||||
reminders?: {
|
||||
useDefault: boolean;
|
||||
overrides?: Array<{ method: string; minutes: number }>;
|
||||
};
|
||||
transparency?: string; // 'opaque' | 'transparent'
|
||||
visibility?: string; // 'default' | 'public' | 'private' | 'confidential'
|
||||
}
|
||||
|
||||
export interface GoogleCalendar {
|
||||
@ -96,9 +103,16 @@ export const getUpcomingEvents = async (
|
||||
description: item.description,
|
||||
start: item.start,
|
||||
end: item.end,
|
||||
attendees: item.attendees,
|
||||
attendees: item.attendees?.map((a: any) => ({
|
||||
email: a.email,
|
||||
displayName: a.displayName,
|
||||
responseStatus: a.responseStatus,
|
||||
})),
|
||||
location: item.location,
|
||||
colorId: item.colorId,
|
||||
reminders: item.reminders,
|
||||
transparency: item.transparency,
|
||||
visibility: item.visibility,
|
||||
})) || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching upcoming events:', error);
|
||||
@ -127,6 +141,10 @@ export const createEvent = async (
|
||||
};
|
||||
if ((event as any).recurrence) requestBody.recurrence = (event as any).recurrence;
|
||||
if ((event as any).source) requestBody.source = (event as any).source;
|
||||
if (event.reminders) requestBody.reminders = event.reminders;
|
||||
if (event.transparency) requestBody.transparency = event.transparency;
|
||||
if (event.visibility) requestBody.visibility = event.visibility;
|
||||
if (event.attendees) requestBody.attendees = event.attendees;
|
||||
const response = await calendar.events.insert({
|
||||
calendarId,
|
||||
requestBody,
|
||||
@ -160,6 +178,10 @@ export const updateEvent = async (
|
||||
};
|
||||
if ((event as any).recurrence) requestBody.recurrence = (event as any).recurrence;
|
||||
if ((event as any).source) requestBody.source = (event as any).source;
|
||||
if (event.reminders) requestBody.reminders = event.reminders;
|
||||
if (event.transparency) requestBody.transparency = event.transparency;
|
||||
if (event.visibility) requestBody.visibility = event.visibility;
|
||||
if (event.attendees) requestBody.attendees = event.attendees;
|
||||
const response = await calendar.events.patch({
|
||||
calendarId,
|
||||
eventId,
|
||||
|
||||
@ -147,7 +147,7 @@ export const getUpcomingEvents = async (
|
||||
const params = new URLSearchParams({
|
||||
startDateTime: startDateTime,
|
||||
endDateTime: endDateTime,
|
||||
'$select': 'subject,bodyPreview,start,end,location,webLink,isAllDay,seriesMasterId,type',
|
||||
'$select': 'subject,bodyPreview,start,end,location,webLink,isAllDay,seriesMasterId,type,reminderMinutesBeforeStart,isReminderOn,showAs,sensitivity,attendees',
|
||||
'$orderby': 'start/dateTime',
|
||||
'$top': '50'
|
||||
});
|
||||
@ -169,7 +169,18 @@ export const getUpcomingEvents = async (
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.value.map((event: any) => ({
|
||||
return data.value.map((event: any) => {
|
||||
// Map Outlook showAs to our busyStatus
|
||||
const showAsMap: Record<string, string> = {
|
||||
'free': 'free', 'tentative': 'tentative', 'busy': 'busy',
|
||||
'oof': 'oof', 'workingElsewhere': 'workingElsewhere',
|
||||
};
|
||||
// Map Outlook sensitivity to our visibility
|
||||
const sensitivityMap: Record<string, string> = {
|
||||
'normal': 'default', 'personal': 'default', 'private': 'private', 'confidential': 'confidential',
|
||||
};
|
||||
|
||||
return {
|
||||
id: event.seriesMasterId ? `${event.seriesMasterId}::${event.id}` : event.id,
|
||||
summary: event.subject,
|
||||
description: event.body?.content || event.bodyPreview,
|
||||
@ -183,8 +194,22 @@ export const getUpcomingEvents = async (
|
||||
},
|
||||
location: event.location?.displayName,
|
||||
htmlLink: event.webLink,
|
||||
allDay: event.isAllDay
|
||||
}));
|
||||
allDay: event.isAllDay,
|
||||
reminders: event.isReminderOn && event.reminderMinutesBeforeStart != null
|
||||
? [{ method: 'popup', minutes: event.reminderMinutesBeforeStart }]
|
||||
: undefined,
|
||||
busyStatus: showAsMap[event.showAs] || undefined,
|
||||
visibility: sensitivityMap[event.sensitivity] || undefined,
|
||||
attendees: event.attendees?.map((a: any) => ({
|
||||
email: a.emailAddress?.address,
|
||||
displayName: a.emailAddress?.name,
|
||||
responseStatus: a.status?.response === 'accepted' ? 'accepted'
|
||||
: a.status?.response === 'declined' ? 'declined'
|
||||
: a.status?.response === 'tentativelyAccepted' ? 'tentative'
|
||||
: 'needsAction',
|
||||
})),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const ensureTimeZone = (dateTimeObj: any) => {
|
||||
@ -222,6 +247,22 @@ export const createEvent = async (
|
||||
displayName: event.location || ''
|
||||
},
|
||||
...(event.recurrence ? { recurrence: event.recurrence } : {}),
|
||||
...(event.reminders?.length ? {
|
||||
isReminderOn: true,
|
||||
reminderMinutesBeforeStart: event.reminders[0].minutes,
|
||||
} : {}),
|
||||
...(event.busyStatus ? { showAs: event.busyStatus === 'oof' ? 'oof' : event.busyStatus } : {}),
|
||||
...(event.visibility ? {
|
||||
sensitivity: event.visibility === 'private' ? 'private'
|
||||
: event.visibility === 'confidential' ? 'confidential'
|
||||
: 'normal'
|
||||
} : {}),
|
||||
...(event.attendees?.length ? {
|
||||
attendees: event.attendees.map((a: any) => ({
|
||||
emailAddress: { address: a.email, name: a.displayName || a.email },
|
||||
type: 'required',
|
||||
})),
|
||||
} : {}),
|
||||
})
|
||||
});
|
||||
|
||||
@ -270,6 +311,22 @@ export const updateEvent = async (
|
||||
displayName: event.location || ''
|
||||
},
|
||||
...(event.recurrence ? { recurrence: event.recurrence } : {}),
|
||||
...(event.reminders?.length ? {
|
||||
isReminderOn: true,
|
||||
reminderMinutesBeforeStart: event.reminders[0].minutes,
|
||||
} : {}),
|
||||
...(event.busyStatus ? { showAs: event.busyStatus === 'oof' ? 'oof' : event.busyStatus } : {}),
|
||||
...(event.visibility ? {
|
||||
sensitivity: event.visibility === 'private' ? 'private'
|
||||
: event.visibility === 'confidential' ? 'confidential'
|
||||
: 'normal'
|
||||
} : {}),
|
||||
...(event.attendees?.length ? {
|
||||
attendees: event.attendees.map((a: any) => ({
|
||||
emailAddress: { address: a.email, name: a.displayName || a.email },
|
||||
type: 'required',
|
||||
})),
|
||||
} : {}),
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@ -11,6 +11,11 @@ export interface SynologyCalendarEvent {
|
||||
url?: string;
|
||||
recurringEventId?: string;
|
||||
isRecurring?: boolean;
|
||||
reminders?: Array<{ method: string; minutes: number }>;
|
||||
attendees?: Array<{ email: string; displayName?: string; responseStatus?: string }>;
|
||||
attachments?: Array<{ url: string; title?: string }>;
|
||||
busyStatus?: string;
|
||||
visibility?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -23,6 +28,95 @@ function formatDateToLocalISO(date: Date): string {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert reminder minutes to iCalendar TRIGGER duration string
|
||||
*/
|
||||
function reminderMinutesToDuration(minutes: number): string {
|
||||
if (minutes === 0) return 'PT0S';
|
||||
const prefix = '-';
|
||||
if (minutes % 10080 === 0) return `${prefix}P${minutes / 10080}W`;
|
||||
if (minutes % 1440 === 0) return `${prefix}P${minutes / 1440}D`;
|
||||
if (minutes % 60 === 0) return `${prefix}PT${minutes / 60}H`;
|
||||
return `${prefix}PT${minutes}M`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract extended properties from a VEVENT component
|
||||
*/
|
||||
function extractExtendedProps(vevent: any): Pick<SynologyCalendarEvent, 'reminders' | 'attendees' | 'attachments' | 'busyStatus' | 'visibility'> {
|
||||
const result: Pick<SynologyCalendarEvent, 'reminders' | 'attendees' | 'attachments' | 'busyStatus' | 'visibility'> = {};
|
||||
|
||||
const valarms = vevent.getAllSubcomponents('valarm');
|
||||
if (valarms && valarms.length > 0) {
|
||||
result.reminders = valarms.map((valarm: any) => {
|
||||
const action = valarm.getFirstPropertyValue('action') || 'DISPLAY';
|
||||
const trigger = valarm.getFirstProperty('trigger');
|
||||
let minutes = 15;
|
||||
if (trigger) {
|
||||
const triggerVal = trigger.getFirstValue();
|
||||
if (triggerVal && typeof triggerVal.toSeconds === 'function') {
|
||||
minutes = Math.abs(Math.round(triggerVal.toSeconds() / 60));
|
||||
} else if (typeof triggerVal === 'string') {
|
||||
const match = triggerVal.match(/^-?PT?(\d+)([MHDS])/i);
|
||||
if (match) {
|
||||
const val = parseInt(match[1]);
|
||||
switch (match[2].toUpperCase()) {
|
||||
case 'M': minutes = val; break;
|
||||
case 'H': minutes = val * 60; break;
|
||||
case 'D': minutes = val * 1440; break;
|
||||
case 'S': minutes = Math.round(val / 60); break;
|
||||
}
|
||||
}
|
||||
const weekMatch = triggerVal.match(/^-?P(\d+)W/i);
|
||||
if (weekMatch) minutes = parseInt(weekMatch[1]) * 10080;
|
||||
const dayMatch = triggerVal.match(/^-?P(\d+)D/i);
|
||||
if (dayMatch) minutes = parseInt(dayMatch[1]) * 1440;
|
||||
}
|
||||
}
|
||||
return { method: action.toString().toLowerCase() === 'email' ? 'email' : 'display', minutes };
|
||||
});
|
||||
}
|
||||
|
||||
const attendeeProps = vevent.getAllProperties('attendee');
|
||||
if (attendeeProps && attendeeProps.length > 0) {
|
||||
result.attendees = attendeeProps.map((prop: any) => {
|
||||
const val = prop.getFirstValue() || '';
|
||||
const email = val.replace(/^mailto:/i, '');
|
||||
const cn = prop.getParameter('cn');
|
||||
const partstat = prop.getParameter('partstat');
|
||||
const statusMap: Record<string, string> = {
|
||||
'ACCEPTED': 'accepted', 'DECLINED': 'declined', 'TENTATIVE': 'tentative', 'NEEDS-ACTION': 'needsAction',
|
||||
};
|
||||
return { email, displayName: cn || undefined, responseStatus: statusMap[partstat?.toUpperCase()] || 'needsAction' };
|
||||
});
|
||||
}
|
||||
|
||||
const attachProps = vevent.getAllProperties('attach');
|
||||
if (attachProps && attachProps.length > 0) {
|
||||
result.attachments = attachProps
|
||||
.map((prop: any) => {
|
||||
const val = prop.getFirstValue();
|
||||
if (typeof val === 'string' && (val.startsWith('http://') || val.startsWith('https://'))) {
|
||||
return { url: val, title: prop.getParameter('filename') || undefined };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as Array<{ url: string; title?: string }>;
|
||||
if (result.attachments.length === 0) delete result.attachments;
|
||||
}
|
||||
|
||||
const transp = vevent.getFirstPropertyValue('transp');
|
||||
if (transp) result.busyStatus = transp.toString().toUpperCase() === 'TRANSPARENT' ? 'free' : 'busy';
|
||||
|
||||
const cls = vevent.getFirstPropertyValue('class');
|
||||
if (cls) {
|
||||
const clsMap: Record<string, string> = { 'PUBLIC': 'public', 'PRIVATE': 'private', 'CONFIDENTIAL': 'confidential' };
|
||||
result.visibility = (clsMap[cls.toString().toUpperCase()] || 'default') as any;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface SynologyCalendar {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -214,6 +308,7 @@ export const getUpcomingEvents = async (
|
||||
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||
recurringEventId: exEvent.uid,
|
||||
isRecurring: true,
|
||||
...extractExtendedProps(exVevent),
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -243,6 +338,7 @@ export const getUpcomingEvents = async (
|
||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||
recurringEventId: event.uid,
|
||||
isRecurring: true,
|
||||
...extractExtendedProps(vevent),
|
||||
});
|
||||
}
|
||||
} catch (expandErr: any) {
|
||||
@ -263,6 +359,7 @@ export const getUpcomingEvents = async (
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||
...extractExtendedProps(vevent),
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -301,6 +398,11 @@ export const createEvent = async (
|
||||
recurrence?: string;
|
||||
start: { dateTime?: string; date?: string };
|
||||
end: { dateTime?: string; date?: string };
|
||||
reminders?: Array<{ method: string; minutes: number }>;
|
||||
attendees?: Array<{ email: string; displayName?: string }>;
|
||||
attachments?: Array<{ url: string; title?: string }>;
|
||||
busyStatus?: string;
|
||||
visibility?: string;
|
||||
}
|
||||
): Promise<SynologyCalendarEvent> => {
|
||||
try {
|
||||
@ -353,6 +455,39 @@ export const createEvent = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Generate VALARM blocks for reminders
|
||||
let valarmLines = '';
|
||||
if (eventData.reminders && eventData.reminders.length > 0) {
|
||||
for (const reminder of eventData.reminders) {
|
||||
const action = reminder.method === 'email' ? 'EMAIL' : 'DISPLAY';
|
||||
const dur = reminderMinutesToDuration(reminder.minutes);
|
||||
valarmLines += `BEGIN:VALARM\r\nACTION:${action}\r\nTRIGGER:${dur}\r\n`;
|
||||
if (action === 'DISPLAY') valarmLines += `DESCRIPTION:Reminder\r\n`;
|
||||
valarmLines += `END:VALARM\r\n`;
|
||||
}
|
||||
}
|
||||
|
||||
let attendeeLines = '';
|
||||
if (eventData.attendees && eventData.attendees.length > 0) {
|
||||
for (const att of eventData.attendees) {
|
||||
const cn = att.displayName ? `;CN=${att.displayName}` : '';
|
||||
attendeeLines += `ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION${cn}:mailto:${att.email}\r\n`;
|
||||
}
|
||||
}
|
||||
|
||||
let attachLines = '';
|
||||
if (eventData.attachments && eventData.attachments.length > 0) {
|
||||
for (const att of eventData.attachments) {
|
||||
attachLines += `ATTACH:${att.url}\r\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const transpLine = eventData.busyStatus === 'free' ? 'TRANSP:TRANSPARENT\r\n' : eventData.busyStatus ? 'TRANSP:OPAQUE\r\n' : '';
|
||||
let classLine = '';
|
||||
if (eventData.visibility && eventData.visibility !== 'default') {
|
||||
classLine = `CLASS:${eventData.visibility.toUpperCase()}\r\n`;
|
||||
}
|
||||
|
||||
const iCalString = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//My Weekly ToDo List//EN
|
||||
@ -362,7 +497,7 @@ DTSTAMP:${dtStamp}
|
||||
DTSTART${dtStartParam}:${dtStart}
|
||||
DTEND${dtEndParam}:${dtEnd}
|
||||
SUMMARY:${eventData.title}
|
||||
${description}${location}${url}${rruleLine}END:VEVENT
|
||||
${description}${location}${url}${rruleLine}${transpLine}${classLine}${attendeeLines}${attachLines}${valarmLines}END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
|
||||
console.log('[SYNOLOGY CALENDAR] Creating event with iCal:', iCalString);
|
||||
@ -418,6 +553,11 @@ export const updateEvent = async (
|
||||
url?: string;
|
||||
start?: { dateTime?: string; date?: string };
|
||||
end?: { dateTime?: string; date?: string };
|
||||
reminders?: Array<{ method: string; minutes: number }>;
|
||||
attendees?: Array<{ email: string; displayName?: string }>;
|
||||
attachments?: Array<{ url: string; title?: string }>;
|
||||
busyStatus?: string;
|
||||
visibility?: string;
|
||||
}
|
||||
): Promise<SynologyCalendarEvent> => {
|
||||
try {
|
||||
@ -503,6 +643,53 @@ export const updateEvent = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Update reminders (VALARM)
|
||||
if (eventData.reminders !== undefined) {
|
||||
const existingAlarms = vevent.getAllSubcomponents('valarm');
|
||||
existingAlarms.forEach((a: any) => vevent.removeSubcomponent(a));
|
||||
for (const reminder of eventData.reminders) {
|
||||
const valarm = new ICAL.Component('valarm');
|
||||
valarm.addPropertyWithValue('action', reminder.method === 'email' ? 'EMAIL' : 'DISPLAY');
|
||||
const dur = ICAL.Duration.fromString(reminderMinutesToDuration(reminder.minutes));
|
||||
valarm.addPropertyWithValue('trigger', dur);
|
||||
if (reminder.method !== 'email') valarm.addPropertyWithValue('description', 'Reminder');
|
||||
vevent.addSubcomponent(valarm);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventData.attendees !== undefined) {
|
||||
vevent.removeAllProperties('attendee');
|
||||
for (const att of eventData.attendees) {
|
||||
const prop = new ICAL.Property('attendee');
|
||||
prop.setValue(`mailto:${att.email}`);
|
||||
prop.setParameter('cutype', 'INDIVIDUAL');
|
||||
prop.setParameter('role', 'REQ-PARTICIPANT');
|
||||
prop.setParameter('partstat', 'NEEDS-ACTION');
|
||||
if (att.displayName) prop.setParameter('cn', att.displayName);
|
||||
vevent.addProperty(prop);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventData.attachments !== undefined) {
|
||||
vevent.removeAllProperties('attach');
|
||||
for (const att of eventData.attachments) {
|
||||
vevent.addPropertyWithValue('attach', att.url);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventData.busyStatus !== undefined) {
|
||||
if (eventData.busyStatus === 'free') vevent.updatePropertyWithValue('transp', 'TRANSPARENT');
|
||||
else if (eventData.busyStatus) vevent.updatePropertyWithValue('transp', 'OPAQUE');
|
||||
}
|
||||
|
||||
if (eventData.visibility !== undefined) {
|
||||
if (eventData.visibility && eventData.visibility !== 'default') {
|
||||
vevent.updatePropertyWithValue('class', eventData.visibility.toUpperCase());
|
||||
} else {
|
||||
vevent.removeProperty('class');
|
||||
}
|
||||
}
|
||||
|
||||
event.sequence = (event.sequence || 0) + 1;
|
||||
if (vevent) {
|
||||
vevent.updatePropertyWithValue('dtstamp', ICAL.Time.now());
|
||||
|
||||
Loading…
Reference in New Issue
Block a user