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",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -39,7 +39,8 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const body = await request.json();
|
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 });
|
console.log('[API] Creating event:', { calendarId, title, start, end });
|
||||||
|
|
||||||
@ -63,6 +64,11 @@ export async function POST(request: NextRequest) {
|
|||||||
allDay: !!allDay,
|
allDay: !!allDay,
|
||||||
recurrence,
|
recurrence,
|
||||||
url,
|
url,
|
||||||
|
reminders,
|
||||||
|
busyStatus,
|
||||||
|
visibility,
|
||||||
|
attendees,
|
||||||
|
attachments,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
// Update cache - await to ensure it's ready before client refreshes
|
// 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 });
|
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const body = await request.json();
|
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 });
|
console.log('[API] Updating event:', { calendarId, eventId, title });
|
||||||
|
|
||||||
@ -110,6 +117,11 @@ export async function PATCH(request: NextRequest) {
|
|||||||
allDay: allDay !== undefined ? !!allDay : undefined,
|
allDay: allDay !== undefined ? !!allDay : undefined,
|
||||||
recurrence,
|
recurrence,
|
||||||
url,
|
url,
|
||||||
|
reminders,
|
||||||
|
busyStatus,
|
||||||
|
visibility,
|
||||||
|
attendees,
|
||||||
|
attachments,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
// Update cache - await to ensure it's ready before client refreshes
|
// 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 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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
|
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
|
||||||
import { faServer } from '@fortawesome/free-solid-svg-icons';
|
import { faServer } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
const RichTextEditor = lazy(() => import('./RichTextEditor'));
|
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 {
|
interface CalendarEventModalProps {
|
||||||
event?: any; // Existing event if editing
|
event?: any;
|
||||||
initialDate?: Date; // If creating new
|
initialDate?: Date;
|
||||||
initialStartTime?: string; // If creating new from slot
|
initialStartTime?: string;
|
||||||
connections: any[]; // To select calendar
|
connections: any[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (eventData: any) => Promise<void>;
|
onSave: (eventData: any) => Promise<void>;
|
||||||
onDelete?: (eventId: string, calendarId: string) => Promise<void>;
|
onDelete?: (eventId: string, calendarId: string) => Promise<void>;
|
||||||
@ -25,7 +54,6 @@ export default function CalendarEventModal({
|
|||||||
onSave,
|
onSave,
|
||||||
onDelete
|
onDelete
|
||||||
}: CalendarEventModalProps) {
|
}: CalendarEventModalProps) {
|
||||||
// Flatten calendars from connections to get selectable options
|
|
||||||
const availableCalendars = connections
|
const availableCalendars = connections
|
||||||
.flatMap(conn => (conn.calendars || []).map((cal: any) => ({
|
.flatMap(conn => (conn.calendars || []).map((cal: any) => ({
|
||||||
...cal,
|
...cal,
|
||||||
@ -35,7 +63,7 @@ export default function CalendarEventModal({
|
|||||||
conn.provider === 'synology' ? 'Synology Calendar' :
|
conn.provider === 'synology' ? 'Synology Calendar' :
|
||||||
'Outlook Calendar'
|
'Outlook Calendar'
|
||||||
})))
|
})))
|
||||||
.filter((cal: any) => cal.editable); // Only editable calendars
|
.filter((cal: any) => cal.editable);
|
||||||
|
|
||||||
const [title, setTitle] = useState(event?.title || '');
|
const [title, setTitle] = useState(event?.title || '');
|
||||||
const [description, setDescription] = useState(event?.description || '');
|
const [description, setDescription] = useState(event?.description || '');
|
||||||
@ -44,13 +72,26 @@ export default function CalendarEventModal({
|
|||||||
const [recurrence, setRecurrence] = useState(event?.recurrence || 'none');
|
const [recurrence, setRecurrence] = useState(event?.recurrence || 'none');
|
||||||
const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : ''));
|
const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : ''));
|
||||||
|
|
||||||
// Date/Time State
|
// New fields
|
||||||
// If event exists, use its start/end.
|
const [reminders, setReminders] = useState<Array<{ method: string; minutes: number }>>(
|
||||||
// If new, use initialDate + initialStartTime.
|
event?.reminders || [{ method: 'display', minutes: 15 }]
|
||||||
// Default duration: 1 hour.
|
);
|
||||||
|
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 = () => {
|
const getInitialStart = () => {
|
||||||
// Support both nested (start.dateTime) and flat (startTime) event formats
|
|
||||||
if (event?.start?.dateTime) return new Date(event.start.dateTime);
|
if (event?.start?.dateTime) return new Date(event.start.dateTime);
|
||||||
if (event?.startTime) return new Date(event.startTime);
|
if (event?.startTime) return new Date(event.startTime);
|
||||||
if (initialDate) {
|
if (initialDate) {
|
||||||
@ -59,7 +100,6 @@ export default function CalendarEventModal({
|
|||||||
const [h, m] = initialStartTime.split(':').map(Number);
|
const [h, m] = initialStartTime.split(':').map(Number);
|
||||||
d.setHours(h, m, 0, 0);
|
d.setHours(h, m, 0, 0);
|
||||||
} else {
|
} else {
|
||||||
// Default to next hour if no time specified (though usually slot click gives time)
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
d.setHours(now.getHours() + 1, 0, 0, 0);
|
d.setHours(now.getHours() + 1, 0, 0, 0);
|
||||||
}
|
}
|
||||||
@ -69,11 +109,10 @@ export default function CalendarEventModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getInitialEnd = () => {
|
const getInitialEnd = () => {
|
||||||
// Support both nested (end.dateTime) and flat (endTime) event formats
|
|
||||||
if (event?.end?.dateTime) return new Date(event.end.dateTime);
|
if (event?.end?.dateTime) return new Date(event.end.dateTime);
|
||||||
if (event?.endTime) return new Date(event.endTime);
|
if (event?.endTime) return new Date(event.endTime);
|
||||||
const start = getInitialStart();
|
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());
|
const [startDate, setStartDate] = useState(getInitialStart());
|
||||||
@ -85,7 +124,6 @@ export default function CalendarEventModal({
|
|||||||
const [isCalendarSelectorOpen, setIsCalendarSelectorOpen] = useState(false);
|
const [isCalendarSelectorOpen, setIsCalendarSelectorOpen] = useState(false);
|
||||||
const calendarSelectorRef = useRef<HTMLDivElement>(null);
|
const calendarSelectorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Close dropdown on outside click
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (calendarSelectorRef.current && !calendarSelectorRef.current.contains(event.target as Node)) {
|
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 selectedCal = availableCalendars.find((c: any) => c.id === calendarId);
|
||||||
const supportsURL = selectedCal && (selectedCal.provider !== 'google' && selectedCal.provider !== 'outlook');
|
const supportsURL = selectedCal && (selectedCal.provider !== 'google' && selectedCal.provider !== 'outlook');
|
||||||
|
const supportsAttachments = selectedCal && (selectedCal.provider === 'apple' || selectedCal.provider === 'synology');
|
||||||
|
|
||||||
const getProviderIcon = (provider: string) => {
|
const getProviderIcon = (provider: string) => {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case 'google': return <FontAwesomeIcon icon={faGoogle} style={{ opacity: 0.8 }} />;
|
case 'google': return <FontAwesomeIcon icon={faGoogle} style={{ opacity: 0.8 }} />;
|
||||||
@ -125,6 +165,9 @@ export default function CalendarEventModal({
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
// Filter out "none" reminders
|
||||||
|
const activeReminders = reminders.filter(r => r.minutes >= 0);
|
||||||
|
|
||||||
await onSave({
|
await onSave({
|
||||||
id: event?.id,
|
id: event?.id,
|
||||||
title,
|
title,
|
||||||
@ -135,7 +178,12 @@ export default function CalendarEventModal({
|
|||||||
calendarId,
|
calendarId,
|
||||||
allDay,
|
allDay,
|
||||||
start: { dateTime: startDate.toISOString() },
|
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();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -152,7 +200,7 @@ export default function CalendarEventModal({
|
|||||||
|
|
||||||
if (!isDeleteConfirming) {
|
if (!isDeleteConfirming) {
|
||||||
setIsDeleteConfirming(true);
|
setIsDeleteConfirming(true);
|
||||||
setTimeout(() => setIsDeleteConfirming(false), 3000); // Reset after 3 seconds
|
setTimeout(() => setIsDeleteConfirming(false), 3000);
|
||||||
return;
|
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 toLocalISOString = (date: Date) => {
|
||||||
const offset = date.getTimezoneOffset() * 60000;
|
const offset = date.getTimezoneOffset() * 60000;
|
||||||
const localISOTime = (new Date(date.getTime() - offset)).toISOString().slice(0, 16);
|
const localISOTime = (new Date(date.getTime() - offset)).toISOString().slice(0, 16);
|
||||||
@ -178,12 +224,60 @@ export default function CalendarEventModal({
|
|||||||
const handleStartDateChange = (val: string) => {
|
const handleStartDateChange = (val: string) => {
|
||||||
const newStart = new Date(val);
|
const newStart = new Date(val);
|
||||||
setStartDate(newStart);
|
setStartDate(newStart);
|
||||||
// Auto-adjust end date if it becomes before start
|
|
||||||
if (endDate <= newStart) {
|
if (endDate <= newStart) {
|
||||||
setEndDate(new Date(newStart.getTime() + 60 * 60 * 1000));
|
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 (
|
return (
|
||||||
<div className="weekly-modal-overlay" onClick={onClose}>
|
<div className="weekly-modal-overlay" onClick={onClose}>
|
||||||
@ -192,7 +286,9 @@ export default function CalendarEventModal({
|
|||||||
padding: '20px',
|
padding: '20px',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
boxShadow: '0 10px 25px rgba(0,0,0,0.15)',
|
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>}
|
{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 */}
|
{/* Calendar row */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 16px', position: 'relative' }}>
|
<div style={{ ...rowStyle, padding: '4px 16px', position: 'relative' }}>
|
||||||
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Calendar</span>
|
<span style={labelStyle}>Calendar</span>
|
||||||
|
|
||||||
<div ref={calendarSelectorRef} style={{ position: 'relative', flex: 1, display: 'flex', justifyContent: 'flex-end' }}>
|
<div ref={calendarSelectorRef} style={{ position: 'relative', flex: 1, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<button
|
<button
|
||||||
@ -246,28 +342,18 @@ export default function CalendarEventModal({
|
|||||||
onClick={() => !event && setIsCalendarSelectorOpen(!isCalendarSelectorOpen)}
|
onClick={() => !event && setIsCalendarSelectorOpen(!isCalendarSelectorOpen)}
|
||||||
disabled={!!event}
|
disabled={!!event}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
alignItems: 'center',
|
background: 'transparent', border: 'none', fontWeight: 500,
|
||||||
gap: '8px',
|
cursor: event ? 'default' : 'pointer', outline: 'none',
|
||||||
background: 'transparent',
|
color: 'var(--weekly-text)', fontSize: '0.95rem',
|
||||||
border: 'none',
|
padding: '4px 0', maxWidth: '200px', justifyContent: 'flex-end'
|
||||||
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' }}>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{selectedCal?.summary || selectedCal?.title || 'Select Calendar'}
|
{selectedCal?.summary || selectedCal?.title || 'Select Calendar'}
|
||||||
</span>
|
</span>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '12px',
|
width: '12px', height: '12px', borderRadius: '50%',
|
||||||
height: '12px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: selectedCal?.backgroundColor || selectedCal?.color || '#3b82f6'
|
backgroundColor: selectedCal?.backgroundColor || selectedCal?.color || '#3b82f6'
|
||||||
}} />
|
}} />
|
||||||
{!event && (isCalendarSelectorOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
|
{!event && (isCalendarSelectorOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
|
||||||
@ -275,39 +361,22 @@ export default function CalendarEventModal({
|
|||||||
|
|
||||||
{isCalendarSelectorOpen && (
|
{isCalendarSelectorOpen && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute', top: '100%', right: 0, zIndex: 100,
|
||||||
top: '100%',
|
minWidth: '220px', backgroundColor: 'var(--weekly-bg-popover, #ffffff)',
|
||||||
right: 0,
|
borderRadius: '10px', boxShadow: '0 4px 15px rgba(0,0,0,0.1)',
|
||||||
zIndex: 100,
|
border: '1px solid var(--weekly-border)', marginTop: '5px',
|
||||||
minWidth: '220px',
|
padding: '6px', maxHeight: '250px', overflowY: 'auto'
|
||||||
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) => (
|
{availableCalendars.map((cal: any) => (
|
||||||
<div
|
<div
|
||||||
key={cal.id}
|
key={cal.id}
|
||||||
onClick={() => {
|
onClick={() => { setCalendarId(cal.id); setIsCalendarSelectorOpen(false); }}
|
||||||
setCalendarId(cal.id);
|
|
||||||
setIsCalendarSelectorOpen(false);
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex', alignItems: 'center', gap: '10px',
|
||||||
alignItems: 'center',
|
padding: '8px 12px', borderRadius: '6px', cursor: 'pointer',
|
||||||
gap: '10px',
|
fontSize: '0.9rem', color: 'var(--weekly-text)',
|
||||||
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',
|
backgroundColor: calendarId === cal.id ? 'var(--weekly-selection, rgba(59, 130, 246, 0.1))' : 'transparent',
|
||||||
transition: 'background 0.2s',
|
transition: 'background 0.2s', textAlign: 'left'
|
||||||
textAlign: 'left'
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--weekly-hover, rgba(0,0,0,0.05))'}
|
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'}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* All Day row */}
|
{/* All Day row */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 16px' }}>
|
<div style={{ ...rowStyle, padding: '4px 16px' }}>
|
||||||
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>All Day</span>
|
<span style={labelStyle}>All Day</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={allDay}
|
checked={allDay}
|
||||||
@ -339,35 +408,29 @@ export default function CalendarEventModal({
|
|||||||
|
|
||||||
{/* Date/Time rows */}
|
{/* Date/Time rows */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '0 16px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '0 16px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={rowStyle}>
|
||||||
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Starts</span>
|
<span style={labelStyle}>Starts</span>
|
||||||
<input
|
<input
|
||||||
type={allDay ? "date" : "datetime-local"}
|
type={allDay ? "date" : "datetime-local"}
|
||||||
value={allDay ? startDate.toISOString().split('T')[0] : toLocalISOString(startDate)}
|
value={allDay ? startDate.toISOString().split('T')[0] : toLocalISOString(startDate)}
|
||||||
onChange={e => handleStartDateChange(e.target.value)}
|
onChange={e => handleStartDateChange(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 8px',
|
padding: '4px 8px', border: 'none', borderRadius: '6px',
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
background: 'var(--weekly-bg-secondary, #f3f4f6)',
|
background: 'var(--weekly-bg-secondary, #f3f4f6)',
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem', textAlign: 'center'
|
||||||
textAlign: 'center'
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={rowStyle}>
|
||||||
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Ends</span>
|
<span style={labelStyle}>Ends</span>
|
||||||
<input
|
<input
|
||||||
type={allDay ? "date" : "datetime-local"}
|
type={allDay ? "date" : "datetime-local"}
|
||||||
value={allDay ? endDate.toISOString().split('T')[0] : toLocalISOString(endDate)}
|
value={allDay ? endDate.toISOString().split('T')[0] : toLocalISOString(endDate)}
|
||||||
onChange={e => setEndDate(new Date(e.target.value))}
|
onChange={e => setEndDate(new Date(e.target.value))}
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 8px',
|
padding: '4px 8px', border: 'none', borderRadius: '6px',
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
background: 'var(--weekly-bg-secondary, #f3f4f6)',
|
background: 'var(--weekly-bg-secondary, #f3f4f6)',
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem', textAlign: 'center'
|
||||||
textAlign: 'center'
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -376,21 +439,9 @@ export default function CalendarEventModal({
|
|||||||
{/* Meta Fields Group */}
|
{/* Meta Fields Group */}
|
||||||
<div style={{ padding: '8px 16px', display: 'flex', flexDirection: 'column', gap: '12px', marginTop: '4px' }}>
|
<div style={{ padding: '8px 16px', display: 'flex', flexDirection: 'column', gap: '12px', marginTop: '4px' }}>
|
||||||
{/* Recurrence */}
|
{/* Recurrence */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={rowStyle}>
|
||||||
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Repeat</span>
|
<span style={labelStyle}>Repeat</span>
|
||||||
<select
|
<select value={recurrence} onChange={e => setRecurrence(e.target.value)} style={selectStyle}>
|
||||||
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)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="none">Never</option>
|
<option value="none">Never</option>
|
||||||
<option value="daily">Every Day</option>
|
<option value="daily">Every Day</option>
|
||||||
<option value="weekly">Every Week</option>
|
<option value="weekly">Every Week</option>
|
||||||
@ -399,6 +450,62 @@ export default function CalendarEventModal({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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) */}
|
{/* URL (Conditional) */}
|
||||||
{supportsURL && (
|
{supportsURL && (
|
||||||
<input
|
<input
|
||||||
@ -407,17 +514,118 @@ export default function CalendarEventModal({
|
|||||||
onChange={e => setUrl(e.target.value)}
|
onChange={e => setUrl(e.target.value)}
|
||||||
placeholder="URL"
|
placeholder="URL"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%', padding: '6px 0', border: 'none',
|
||||||
padding: '6px 0',
|
|
||||||
border: 'none',
|
|
||||||
borderBottom: '1px solid var(--weekly-border)',
|
borderBottom: '1px solid var(--weekly-border)',
|
||||||
background: 'transparent',
|
background: 'transparent', fontSize: '0.9rem', outline: 'none'
|
||||||
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 */}
|
{/* Notes */}
|
||||||
<div style={{ marginTop: '4px' }}>
|
<div style={{ marginTop: '4px' }}>
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
@ -447,15 +655,9 @@ export default function CalendarEventModal({
|
|||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={isSaving || isDeleting}
|
disabled={isSaving || isDeleting}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 12px',
|
padding: '8px 12px', background: 'none', color: '#ef4444',
|
||||||
background: 'none',
|
border: 'none', borderRadius: '6px', fontSize: '0.9rem',
|
||||||
color: '#ef4444',
|
fontWeight: 500, cursor: 'pointer', transition: 'all 0.2s',
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
opacity: isSaving || isDeleting ? 0.5 : 1
|
opacity: isSaving || isDeleting ? 0.5 : 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -480,14 +682,9 @@ export default function CalendarEventModal({
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isSaving || isDeleting}
|
disabled={isSaving || isDeleting}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 20px',
|
padding: '8px 20px', borderRadius: '8px', fontSize: '0.95rem',
|
||||||
borderRadius: '8px',
|
fontWeight: 600, backgroundColor: '#3b82f6', color: 'white',
|
||||||
fontSize: '0.95rem',
|
border: 'none', cursor: 'pointer',
|
||||||
fontWeight: 600,
|
|
||||||
backgroundColor: '#3b82f6',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
opacity: isSaving || isDeleting ? 0.7 : 1
|
opacity: isSaving || isDeleting ? 0.7 : 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -14,6 +14,11 @@ export interface AppleCalendarEvent {
|
|||||||
url?: string;
|
url?: string;
|
||||||
recurringEventId?: string;
|
recurringEventId?: string;
|
||||||
isRecurring?: boolean;
|
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}`;
|
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 {
|
export interface AppleCalendar {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -182,6 +303,7 @@ export const getUpcomingEvents = async (
|
|||||||
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
|
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||||
recurringEventId: exEvent.uid,
|
recurringEventId: exEvent.uid,
|
||||||
isRecurring: true,
|
isRecurring: true,
|
||||||
|
...extractExtendedProps(exVevent),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -217,6 +339,7 @@ export const getUpcomingEvents = async (
|
|||||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||||
recurringEventId: event.uid,
|
recurringEventId: event.uid,
|
||||||
isRecurring: true,
|
isRecurring: true,
|
||||||
|
...extractExtendedProps(vevent),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (expandErr: any) {
|
} catch (expandErr: any) {
|
||||||
@ -238,6 +361,7 @@ export const getUpcomingEvents = async (
|
|||||||
description: event.description,
|
description: event.description,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||||
|
...extractExtendedProps(vevent),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -268,6 +392,11 @@ export const createEvent = async (
|
|||||||
recurrence?: string;
|
recurrence?: string;
|
||||||
start: { dateTime?: string; date?: string };
|
start: { dateTime?: string; date?: string };
|
||||||
end: { 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> => {
|
): Promise<AppleCalendarEvent> => {
|
||||||
try {
|
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
|
const iCalString = `BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
PRODID:-//My Weekly ToDo List//EN
|
PRODID:-//My Weekly ToDo List//EN
|
||||||
@ -339,7 +506,7 @@ DTSTAMP:${dtStamp}
|
|||||||
DTSTART${dtStartParam}:${dtStart}
|
DTSTART${dtStartParam}:${dtStart}
|
||||||
DTEND${dtEndParam}:${dtEnd}
|
DTEND${dtEndParam}:${dtEnd}
|
||||||
SUMMARY:${eventData.title}
|
SUMMARY:${eventData.title}
|
||||||
${description}${location}${url}${rruleLine}END:VEVENT
|
${description}${location}${url}${rruleLine}${transpLine}${classLine}${attendeeLines}${attachLines}${valarmLines}END:VEVENT
|
||||||
END:VCALENDAR`;
|
END:VCALENDAR`;
|
||||||
|
|
||||||
console.log('[APPLE CALENDAR] Creating event with iCal:', iCalString);
|
console.log('[APPLE CALENDAR] Creating event with iCal:', iCalString);
|
||||||
@ -382,6 +549,11 @@ export const updateEvent = async (
|
|||||||
url?: string;
|
url?: string;
|
||||||
start?: { dateTime?: string; date?: string };
|
start?: { dateTime?: string; date?: string };
|
||||||
end?: { 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> => {
|
): Promise<AppleCalendarEvent> => {
|
||||||
try {
|
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
|
// Bump sequence
|
||||||
event.sequence = (event.sequence || 0) + 1;
|
event.sequence = (event.sequence || 0) + 1;
|
||||||
if (vevent) {
|
if (vevent) {
|
||||||
|
|||||||
@ -6,6 +6,25 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
|
|
||||||
const prisma = new PrismaClient();
|
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 {
|
export interface CalendarEvent {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -28,6 +47,11 @@ export interface CalendarEvent {
|
|||||||
calendarTitle: string;
|
calendarTitle: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
allDay?: boolean;
|
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) => {
|
events = events.concat(calendarEvents.map((event: any) => {
|
||||||
const eventColor = event.colorId ? getGoogleEventColor(event.colorId) : calendarData?.backgroundColor;
|
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 {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
title: event.summary || '(No Title)', // Map summary to title
|
title: event.summary || '(No Title)',
|
||||||
description: event.description,
|
description: event.description,
|
||||||
start: event.start,
|
start: event.start,
|
||||||
end: event.end,
|
end: event.end,
|
||||||
@ -305,7 +337,15 @@ export const getCalendarEvents = async (
|
|||||||
source: 'google' as const,
|
source: 'google' as const,
|
||||||
calendarId,
|
calendarId,
|
||||||
calendarTitle: calendarData?.summary || calendarData?.title || 'Google Calendar',
|
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) {
|
} catch (calError) {
|
||||||
@ -346,7 +386,6 @@ export const getCalendarEvents = async (
|
|||||||
console.log(`[CALENDAR] Fetched ${calendarEvents.length} events from calendar ${calendarId}`);
|
console.log(`[CALENDAR] Fetched ${calendarEvents.length} events from calendar ${calendarId}`);
|
||||||
|
|
||||||
events = events.concat(calendarEvents.map((event: any) => {
|
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 isDateOnly = (s: string) => s && !s.includes('T');
|
||||||
const startIsAllDay = isDateOnly(event.startDate);
|
const startIsAllDay = isDateOnly(event.startDate);
|
||||||
return {
|
return {
|
||||||
@ -368,7 +407,12 @@ export const getCalendarEvents = async (
|
|||||||
source: 'apple' as const,
|
source: 'apple' as const,
|
||||||
calendarId,
|
calendarId,
|
||||||
calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar',
|
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,
|
source: 'synology' as const,
|
||||||
calendarId,
|
calendarId,
|
||||||
calendarTitle: calendarData?.title || 'Synology Calendar',
|
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) {
|
} catch (calError) {
|
||||||
@ -495,7 +544,11 @@ export const getCalendarEvents = async (
|
|||||||
source: 'outlook' as const,
|
source: 'outlook' as const,
|
||||||
calendarId,
|
calendarId,
|
||||||
calendarTitle: calendarData?.title || 'Outlook Calendar',
|
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) {
|
} catch (calError) {
|
||||||
@ -641,6 +694,12 @@ export const createCalendarEvent = async (
|
|||||||
...(rrule ? { recurrence: [rrule] } : {}),
|
...(rrule ? { recurrence: [rrule] } : {}),
|
||||||
...(event.allDay ? { allDay: true } : {}),
|
...(event.allDay ? { allDay: true } : {}),
|
||||||
...(event.url ? { source: { url: event.url, title: event.url } } : {}),
|
...(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 =>
|
const createdEvent = await import('./google-calendar').then(m =>
|
||||||
@ -678,6 +737,10 @@ export const createCalendarEvent = async (
|
|||||||
location: event.location,
|
location: event.location,
|
||||||
allDay: event.allDay,
|
allDay: event.allDay,
|
||||||
recurrence: toOutlookRecurrence(event.recurrence, startDate),
|
recurrence: toOutlookRecurrence(event.recurrence, startDate),
|
||||||
|
reminders: event.reminders,
|
||||||
|
busyStatus: event.busyStatus,
|
||||||
|
visibility: event.visibility,
|
||||||
|
attendees: event.attendees,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -731,7 +794,12 @@ export const createCalendarEvent = async (
|
|||||||
url: event.url,
|
url: event.url,
|
||||||
recurrence: event.recurrence,
|
recurrence: event.recurrence,
|
||||||
start,
|
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,
|
url: event.url,
|
||||||
recurrence: event.recurrence,
|
recurrence: event.recurrence,
|
||||||
start: event.start!,
|
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 (event.location !== undefined) googleEvent.location = event.location;
|
||||||
if (rrule) googleEvent.recurrence = [rrule];
|
if (rrule) googleEvent.recurrence = [rrule];
|
||||||
if (event.url) googleEvent.source = { url: event.url, title: event.url };
|
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.
|
// Google adds _date suffix for instances. Editing base series only.
|
||||||
const baseEventId = eventId.split('_')[0];
|
const baseEventId = eventId.split('_')[0];
|
||||||
@ -855,6 +934,10 @@ export const updateCalendarEvent = async (
|
|||||||
location: event.location,
|
location: event.location,
|
||||||
allDay: event.allDay,
|
allDay: event.allDay,
|
||||||
recurrence: toOutlookRecurrence(event.recurrence, startDate),
|
recurrence: toOutlookRecurrence(event.recurrence, startDate),
|
||||||
|
reminders: event.reminders,
|
||||||
|
busyStatus: event.busyStatus,
|
||||||
|
visibility: event.visibility,
|
||||||
|
attendees: event.attendees,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -897,7 +980,12 @@ export const updateCalendarEvent = async (
|
|||||||
location: event.location,
|
location: event.location,
|
||||||
url: event.url,
|
url: event.url,
|
||||||
start: event.start,
|
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,
|
location: event.location,
|
||||||
url: event.url,
|
url: event.url,
|
||||||
start: event.start,
|
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<{
|
attendees?: Array<{
|
||||||
email: string;
|
email: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
responseStatus?: string;
|
||||||
}>;
|
}>;
|
||||||
location?: string;
|
location?: string;
|
||||||
colorId?: 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 {
|
export interface GoogleCalendar {
|
||||||
@ -96,9 +103,16 @@ export const getUpcomingEvents = async (
|
|||||||
description: item.description,
|
description: item.description,
|
||||||
start: item.start,
|
start: item.start,
|
||||||
end: item.end,
|
end: item.end,
|
||||||
attendees: item.attendees,
|
attendees: item.attendees?.map((a: any) => ({
|
||||||
|
email: a.email,
|
||||||
|
displayName: a.displayName,
|
||||||
|
responseStatus: a.responseStatus,
|
||||||
|
})),
|
||||||
location: item.location,
|
location: item.location,
|
||||||
colorId: item.colorId,
|
colorId: item.colorId,
|
||||||
|
reminders: item.reminders,
|
||||||
|
transparency: item.transparency,
|
||||||
|
visibility: item.visibility,
|
||||||
})) || [];
|
})) || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching upcoming events:', 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).recurrence) requestBody.recurrence = (event as any).recurrence;
|
||||||
if ((event as any).source) requestBody.source = (event as any).source;
|
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({
|
const response = await calendar.events.insert({
|
||||||
calendarId,
|
calendarId,
|
||||||
requestBody,
|
requestBody,
|
||||||
@ -160,6 +178,10 @@ export const updateEvent = async (
|
|||||||
};
|
};
|
||||||
if ((event as any).recurrence) requestBody.recurrence = (event as any).recurrence;
|
if ((event as any).recurrence) requestBody.recurrence = (event as any).recurrence;
|
||||||
if ((event as any).source) requestBody.source = (event as any).source;
|
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({
|
const response = await calendar.events.patch({
|
||||||
calendarId,
|
calendarId,
|
||||||
eventId,
|
eventId,
|
||||||
|
|||||||
@ -147,7 +147,7 @@ export const getUpcomingEvents = async (
|
|||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
startDateTime: startDateTime,
|
startDateTime: startDateTime,
|
||||||
endDateTime: endDateTime,
|
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',
|
'$orderby': 'start/dateTime',
|
||||||
'$top': '50'
|
'$top': '50'
|
||||||
});
|
});
|
||||||
@ -169,7 +169,18 @@ export const getUpcomingEvents = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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,
|
id: event.seriesMasterId ? `${event.seriesMasterId}::${event.id}` : event.id,
|
||||||
summary: event.subject,
|
summary: event.subject,
|
||||||
description: event.body?.content || event.bodyPreview,
|
description: event.body?.content || event.bodyPreview,
|
||||||
@ -183,8 +194,22 @@ export const getUpcomingEvents = async (
|
|||||||
},
|
},
|
||||||
location: event.location?.displayName,
|
location: event.location?.displayName,
|
||||||
htmlLink: event.webLink,
|
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) => {
|
const ensureTimeZone = (dateTimeObj: any) => {
|
||||||
@ -222,6 +247,22 @@ export const createEvent = async (
|
|||||||
displayName: event.location || ''
|
displayName: event.location || ''
|
||||||
},
|
},
|
||||||
...(event.recurrence ? { recurrence: event.recurrence } : {}),
|
...(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 || ''
|
displayName: event.location || ''
|
||||||
},
|
},
|
||||||
...(event.recurrence ? { recurrence: event.recurrence } : {}),
|
...(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;
|
url?: string;
|
||||||
recurringEventId?: string;
|
recurringEventId?: string;
|
||||||
isRecurring?: boolean;
|
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}`;
|
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 {
|
export interface SynologyCalendar {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -214,6 +308,7 @@ export const getUpcomingEvents = async (
|
|||||||
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
|
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||||
recurringEventId: exEvent.uid,
|
recurringEventId: exEvent.uid,
|
||||||
isRecurring: true,
|
isRecurring: true,
|
||||||
|
...extractExtendedProps(exVevent),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -243,6 +338,7 @@ export const getUpcomingEvents = async (
|
|||||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||||
recurringEventId: event.uid,
|
recurringEventId: event.uid,
|
||||||
isRecurring: true,
|
isRecurring: true,
|
||||||
|
...extractExtendedProps(vevent),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (expandErr: any) {
|
} catch (expandErr: any) {
|
||||||
@ -263,6 +359,7 @@ export const getUpcomingEvents = async (
|
|||||||
description: event.description,
|
description: event.description,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||||
|
...extractExtendedProps(vevent),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -301,6 +398,11 @@ export const createEvent = async (
|
|||||||
recurrence?: string;
|
recurrence?: string;
|
||||||
start: { dateTime?: string; date?: string };
|
start: { dateTime?: string; date?: string };
|
||||||
end: { 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> => {
|
): Promise<SynologyCalendarEvent> => {
|
||||||
try {
|
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
|
const iCalString = `BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
PRODID:-//My Weekly ToDo List//EN
|
PRODID:-//My Weekly ToDo List//EN
|
||||||
@ -362,7 +497,7 @@ DTSTAMP:${dtStamp}
|
|||||||
DTSTART${dtStartParam}:${dtStart}
|
DTSTART${dtStartParam}:${dtStart}
|
||||||
DTEND${dtEndParam}:${dtEnd}
|
DTEND${dtEndParam}:${dtEnd}
|
||||||
SUMMARY:${eventData.title}
|
SUMMARY:${eventData.title}
|
||||||
${description}${location}${url}${rruleLine}END:VEVENT
|
${description}${location}${url}${rruleLine}${transpLine}${classLine}${attendeeLines}${attachLines}${valarmLines}END:VEVENT
|
||||||
END:VCALENDAR`;
|
END:VCALENDAR`;
|
||||||
|
|
||||||
console.log('[SYNOLOGY CALENDAR] Creating event with iCal:', iCalString);
|
console.log('[SYNOLOGY CALENDAR] Creating event with iCal:', iCalString);
|
||||||
@ -418,6 +553,11 @@ export const updateEvent = async (
|
|||||||
url?: string;
|
url?: string;
|
||||||
start?: { dateTime?: string; date?: string };
|
start?: { dateTime?: string; date?: string };
|
||||||
end?: { 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> => {
|
): Promise<SynologyCalendarEvent> => {
|
||||||
try {
|
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;
|
event.sequence = (event.sequence || 0) + 1;
|
||||||
if (vevent) {
|
if (vevent) {
|
||||||
vevent.updatePropertyWithValue('dtstamp', ICAL.Time.now());
|
vevent.updatePropertyWithValue('dtstamp', ICAL.Time.now());
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user