feat: sync calendar colors from all providers and add custom recurrence
- Store calendar colors (backgroundColor) from Apple and Synology during connection setup - Auto-refresh missing colors for existing Apple/Synology connections - Show color dots next to calendar names in settings - Add "Custom..." recurrence option with interval (every N days/weeks/ months/years) and day-of-week selection for weekly recurrence - Generate proper RRULE with INTERVAL and BYDAY for all providers - Generate proper Outlook Graph recurrence with custom intervals v1.61.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1ab98025a6
commit
c5dc315f82
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.60.0",
|
"version": "1.61.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": {
|
||||||
|
|||||||
@ -57,6 +57,7 @@ export async function POST(req: Request) {
|
|||||||
id: cal.id,
|
id: cal.id,
|
||||||
title: cal.title,
|
title: cal.title,
|
||||||
isPrimary: cal.isPrimary,
|
isPrimary: cal.isPrimary,
|
||||||
|
backgroundColor: cal.color || undefined,
|
||||||
selected: true // Default to selected
|
selected: true // Default to selected
|
||||||
})) as any,
|
})) as any,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth";
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache';
|
import { refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache';
|
||||||
import { getUserCalendars as getSynologyCalendars } from '@/lib/synology-calendar';
|
import { getUserCalendars as getSynologyCalendars } from '@/lib/synology-calendar';
|
||||||
|
import { getUserCalendars as getAppleCalendars } from '@/lib/apple-calendar';
|
||||||
|
|
||||||
// Get user's calendar connections
|
// Get user's calendar connections
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@ -42,7 +43,7 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Synology connections, fire-and-forget prune of deleted calendars
|
// For Synology connections, fire-and-forget prune of deleted calendars + refresh colors
|
||||||
if (conn.provider === 'synology' && Array.isArray(calendars) && calendars.length > 0) {
|
if (conn.provider === 'synology' && Array.isArray(calendars) && calendars.length > 0) {
|
||||||
const connId = conn.id;
|
const connId = conn.id;
|
||||||
const storedCalendars = calendars;
|
const storedCalendars = calendars;
|
||||||
@ -52,14 +53,20 @@ export async function GET(request: NextRequest) {
|
|||||||
const serverUrl = conn.refreshToken;
|
const serverUrl = conn.refreshToken;
|
||||||
if (username && password && serverUrl) {
|
if (username && password && serverUrl) {
|
||||||
const freshCalendars = await getSynologyCalendars(serverUrl, username, password);
|
const freshCalendars = await getSynologyCalendars(serverUrl, username, password);
|
||||||
const freshIds = new Set(freshCalendars.map(c => c.id));
|
const freshMap = new Map(freshCalendars.map(c => [c.id, c]));
|
||||||
const staleIds = storedCalendars.filter((c: any) => !freshIds.has(c.id)).map((c: any) => c.id);
|
const staleIds = storedCalendars.filter((c: any) => !freshMap.has(c.id)).map((c: any) => c.id);
|
||||||
if (staleIds.length > 0) {
|
const hasMissingColors = storedCalendars.some((c: any) => !c.backgroundColor);
|
||||||
console.log('[CONNECTIONS] Synology stale calendars to prune:', staleIds);
|
if (staleIds.length > 0 || hasMissingColors) {
|
||||||
const pruned = storedCalendars.filter((c: any) => freshIds.has(c.id));
|
console.log('[CONNECTIONS] Synology refresh: pruning', staleIds.length, 'stale, refreshing colors:', hasMissingColors);
|
||||||
|
const updated = storedCalendars
|
||||||
|
.filter((c: any) => freshMap.has(c.id))
|
||||||
|
.map((c: any) => {
|
||||||
|
const fresh = freshMap.get(c.id);
|
||||||
|
return { ...c, backgroundColor: c.backgroundColor || fresh?.color || undefined };
|
||||||
|
});
|
||||||
await prisma.calendarConnection.update({
|
await prisma.calendarConnection.update({
|
||||||
where: { id: connId },
|
where: { id: connId },
|
||||||
data: { calendars: pruned },
|
data: { calendars: updated },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -69,6 +76,35 @@ export async function GET(request: NextRequest) {
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Apple connections, fire-and-forget refresh of missing colors
|
||||||
|
if (conn.provider === 'apple' && Array.isArray(calendars) && calendars.length > 0) {
|
||||||
|
const connId = conn.id;
|
||||||
|
const storedCalendars = calendars;
|
||||||
|
const hasMissingColors = storedCalendars.some((c: any) => !c.backgroundColor);
|
||||||
|
if (hasMissingColors) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [email, appPassword] = conn.accessToken.split(':');
|
||||||
|
if (email && appPassword) {
|
||||||
|
const freshCalendars = await getAppleCalendars(email, appPassword);
|
||||||
|
const freshMap = new Map(freshCalendars.map(c => [c.id, c]));
|
||||||
|
const updated = storedCalendars.map((c: any) => {
|
||||||
|
const fresh = freshMap.get(c.id);
|
||||||
|
return { ...c, backgroundColor: c.backgroundColor || fresh?.color || undefined };
|
||||||
|
});
|
||||||
|
await prisma.calendarConnection.update({
|
||||||
|
where: { id: connId },
|
||||||
|
data: { calendars: updated },
|
||||||
|
});
|
||||||
|
console.log('[CONNECTIONS] Apple calendars refreshed with colors');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CONNECTIONS] Apple calendar color refresh failed:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: conn.id,
|
id: conn.id,
|
||||||
provider: conn.provider,
|
provider: conn.provider,
|
||||||
|
|||||||
@ -39,7 +39,7 @@ 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, recurrenceEndDate, recurrenceCount, url,
|
const { calendarId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, recurrenceInterval, recurrenceDays, url,
|
||||||
reminders, busyStatus, visibility, attendees, attachments } = body;
|
reminders, busyStatus, visibility, attendees, attachments } = body;
|
||||||
|
|
||||||
console.log('[API] Creating event:', { calendarId, title, start, end });
|
console.log('[API] Creating event:', { calendarId, title, start, end });
|
||||||
@ -65,6 +65,8 @@ export async function POST(request: NextRequest) {
|
|||||||
recurrence,
|
recurrence,
|
||||||
recurrenceEndDate,
|
recurrenceEndDate,
|
||||||
recurrenceCount,
|
recurrenceCount,
|
||||||
|
recurrenceInterval,
|
||||||
|
recurrenceDays,
|
||||||
url,
|
url,
|
||||||
reminders,
|
reminders,
|
||||||
busyStatus,
|
busyStatus,
|
||||||
@ -94,7 +96,7 @@ 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, recurrenceEndDate, recurrenceCount, url,
|
const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, recurrenceInterval, recurrenceDays, url,
|
||||||
reminders, busyStatus, visibility, attendees, attachments } = body;
|
reminders, busyStatus, visibility, attendees, attachments } = body;
|
||||||
|
|
||||||
console.log('[API] Updating event:', { calendarId, eventId, title });
|
console.log('[API] Updating event:', { calendarId, eventId, title });
|
||||||
@ -120,6 +122,8 @@ export async function PATCH(request: NextRequest) {
|
|||||||
recurrence,
|
recurrence,
|
||||||
recurrenceEndDate,
|
recurrenceEndDate,
|
||||||
recurrenceCount,
|
recurrenceCount,
|
||||||
|
recurrenceInterval,
|
||||||
|
recurrenceDays,
|
||||||
url,
|
url,
|
||||||
reminders,
|
reminders,
|
||||||
busyStatus,
|
busyStatus,
|
||||||
|
|||||||
@ -71,6 +71,7 @@ export async function POST(req: Request) {
|
|||||||
id: cal.id,
|
id: cal.id,
|
||||||
title: cal.title,
|
title: cal.title,
|
||||||
isPrimary: cal.isPrimary,
|
isPrimary: cal.isPrimary,
|
||||||
|
backgroundColor: cal.color || undefined,
|
||||||
selected: true
|
selected: true
|
||||||
})) as any
|
})) as any
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,6 +77,11 @@ export default function CalendarEventModal({
|
|||||||
event?.recurrenceEndDate || ''
|
event?.recurrenceEndDate || ''
|
||||||
);
|
);
|
||||||
const [recurrenceCount, setRecurrenceCount] = useState(event?.recurrenceCount || 10);
|
const [recurrenceCount, setRecurrenceCount] = useState(event?.recurrenceCount || 10);
|
||||||
|
// Custom recurrence fields
|
||||||
|
const [customInterval, setCustomInterval] = useState(event?.recurrenceInterval || 1);
|
||||||
|
const [customUnit, setCustomUnit] = useState<'days' | 'weeks' | 'months' | 'years'>(event?.recurrenceUnit || 'weeks');
|
||||||
|
const startDow = (() => { const d = event?.start?.dateTime ? new Date(event.start.dateTime) : new Date(); return d.getDay(); })();
|
||||||
|
const [customDays, setCustomDays] = useState<number[]>(event?.recurrenceDays || [startDow]);
|
||||||
const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : ''));
|
const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : ''));
|
||||||
|
|
||||||
// New fields
|
// New fields
|
||||||
@ -179,7 +184,10 @@ export default function CalendarEventModal({
|
|||||||
description,
|
description,
|
||||||
location,
|
location,
|
||||||
url: url || undefined,
|
url: url || undefined,
|
||||||
recurrence: recurrence !== 'none' ? recurrence : undefined,
|
recurrence: recurrence === 'custom' ? customUnit.replace(/s$/, '') === 'day' ? 'daily' : customUnit.replace(/s$/, '') === 'week' ? 'weekly' : customUnit.replace(/s$/, '') === 'month' ? 'monthly' : 'yearly'
|
||||||
|
: recurrence !== 'none' ? recurrence : undefined,
|
||||||
|
recurrenceInterval: recurrence === 'custom' ? customInterval : undefined,
|
||||||
|
recurrenceDays: recurrence === 'custom' && customUnit === 'weeks' ? customDays : undefined,
|
||||||
recurrenceEndDate: recurrence !== 'none' && recurrenceEndType === 'date' && recurrenceEndDateValue ? recurrenceEndDateValue : undefined,
|
recurrenceEndDate: recurrence !== 'none' && recurrenceEndType === 'date' && recurrenceEndDateValue ? recurrenceEndDateValue : undefined,
|
||||||
recurrenceCount: recurrence !== 'none' && recurrenceEndType === 'count' && recurrenceCount > 0 ? recurrenceCount : undefined,
|
recurrenceCount: recurrence !== 'none' && recurrenceEndType === 'count' && recurrenceCount > 0 ? recurrenceCount : undefined,
|
||||||
calendarId,
|
calendarId,
|
||||||
@ -443,9 +451,62 @@ export default function CalendarEventModal({
|
|||||||
<option value="weekly">Every Week</option>
|
<option value="weekly">Every Week</option>
|
||||||
<option value="monthly">Every Month</option>
|
<option value="monthly">Every Month</option>
|
||||||
<option value="yearly">Every Year</option>
|
<option value="yearly">Every Year</option>
|
||||||
|
<option value="custom">Custom...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom recurrence options */}
|
||||||
|
{recurrence === 'custom' && (
|
||||||
|
<>
|
||||||
|
<div style={{ ...rowStyle, gap: '6px' }}>
|
||||||
|
<span style={labelStyle}>Every</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={99} value={customInterval}
|
||||||
|
onChange={e => setCustomInterval(Math.max(1, parseInt(e.target.value) || 1))}
|
||||||
|
style={{
|
||||||
|
...selectStyle, width: '45px', textAlign: 'center',
|
||||||
|
background: 'var(--weekly-bg-secondary, #f5f5f5)', borderRadius: '6px', padding: '4px 6px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select value={customUnit} onChange={e => setCustomUnit(e.target.value as any)} style={selectStyle}>
|
||||||
|
<option value="days">{customInterval === 1 ? 'Day' : 'Days'}</option>
|
||||||
|
<option value="weeks">{customInterval === 1 ? 'Week' : 'Weeks'}</option>
|
||||||
|
<option value="months">{customInterval === 1 ? 'Month' : 'Months'}</option>
|
||||||
|
<option value="years">{customInterval === 1 ? 'Year' : 'Years'}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{customUnit === 'weeks' && (
|
||||||
|
<div style={{ ...rowStyle, flexDirection: 'column', alignItems: 'flex-start', gap: '6px' }}>
|
||||||
|
<span style={labelStyle}>Repeat on</span>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', paddingLeft: '0' }}>
|
||||||
|
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => {
|
||||||
|
setCustomDays(prev =>
|
||||||
|
prev.includes(i) ? (prev.length > 1 ? prev.filter(d => d !== i) : prev) : [...prev, i]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '30px', height: '30px', borderRadius: '50%',
|
||||||
|
border: customDays.includes(i) ? '2px solid #3b82f6' : '1px solid var(--weekly-border, #ddd)',
|
||||||
|
backgroundColor: customDays.includes(i) ? '#3b82f6' : 'transparent',
|
||||||
|
color: customDays.includes(i) ? 'white' : 'var(--weekly-text)',
|
||||||
|
fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Recurrence End - only shown when repeat is set */}
|
{/* Recurrence End - only shown when repeat is set */}
|
||||||
{recurrence !== 'none' && (
|
{recurrence !== 'none' && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -12405,7 +12405,7 @@ function SettingsSidebar({
|
|||||||
padding: "3px 0",
|
padding: "3px 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Calendar Name */}
|
{/* Calendar Color + Name */}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -12414,8 +12414,15 @@ function SettingsSidebar({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<span style={{
|
||||||
|
width: "10px", height: "10px", borderRadius: "50%", flexShrink: 0,
|
||||||
|
backgroundColor: cal.backgroundColor || cal.color || "#3b82f6",
|
||||||
|
}} />
|
||||||
{cleanTitle}
|
{cleanTitle}
|
||||||
{isShared && (
|
{isShared && (
|
||||||
<span title="Shared calendar" style={{ marginLeft: "4px", fontSize: "0.75rem", opacity: 0.5 }}>
|
<span title="Shared calendar" style={{ marginLeft: "4px", fontSize: "0.75rem", opacity: 0.5 }}>
|
||||||
|
|||||||
@ -392,6 +392,8 @@ export const createEvent = async (
|
|||||||
recurrence?: string;
|
recurrence?: string;
|
||||||
recurrenceEndDate?: string;
|
recurrenceEndDate?: string;
|
||||||
recurrenceCount?: number;
|
recurrenceCount?: number;
|
||||||
|
recurrenceInterval?: number;
|
||||||
|
recurrenceDays?: number[];
|
||||||
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 }>;
|
reminders?: Array<{ method: string; minutes: number }>;
|
||||||
@ -458,6 +460,13 @@ export const createEvent = async (
|
|||||||
};
|
};
|
||||||
if (rruleMap[eventData.recurrence]) {
|
if (rruleMap[eventData.recurrence]) {
|
||||||
let rrule = rruleMap[eventData.recurrence];
|
let rrule = rruleMap[eventData.recurrence];
|
||||||
|
if (eventData.recurrenceInterval && eventData.recurrenceInterval > 1 && eventData.recurrence !== 'biweekly') {
|
||||||
|
rrule += `;INTERVAL=${eventData.recurrenceInterval}`;
|
||||||
|
}
|
||||||
|
if (eventData.recurrenceDays && eventData.recurrenceDays.length > 0 && eventData.recurrence === 'weekly') {
|
||||||
|
const dayMap = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
|
||||||
|
rrule += `;BYDAY=${eventData.recurrenceDays.map(d => dayMap[d]).join(',')}`;
|
||||||
|
}
|
||||||
if (eventData.recurrenceCount && eventData.recurrenceCount > 0) {
|
if (eventData.recurrenceCount && eventData.recurrenceCount > 0) {
|
||||||
rrule += `;COUNT=${eventData.recurrenceCount}`;
|
rrule += `;COUNT=${eventData.recurrenceCount}`;
|
||||||
} else if (eventData.recurrenceEndDate) {
|
} else if (eventData.recurrenceEndDate) {
|
||||||
|
|||||||
@ -43,6 +43,8 @@ export interface CalendarEvent {
|
|||||||
recurrence?: string;
|
recurrence?: string;
|
||||||
recurrenceEndDate?: string;
|
recurrenceEndDate?: string;
|
||||||
recurrenceCount?: number;
|
recurrenceCount?: number;
|
||||||
|
recurrenceInterval?: number;
|
||||||
|
recurrenceDays?: number[];
|
||||||
recurringEventId?: string;
|
recurringEventId?: string;
|
||||||
isRecurring?: boolean;
|
isRecurring?: boolean;
|
||||||
source: 'google' | 'apple' | 'outlook' | 'synology' | 'notion';
|
source: 'google' | 'apple' | 'outlook' | 'synology' | 'notion';
|
||||||
@ -60,7 +62,7 @@ export interface CalendarEvent {
|
|||||||
/**
|
/**
|
||||||
* Convert friendly recurrence name to RRULE string
|
* Convert friendly recurrence name to RRULE string
|
||||||
*/
|
*/
|
||||||
function toRRule(recurrence?: string, recurrenceEndDate?: string, recurrenceCount?: number): string | null {
|
function toRRule(recurrence?: string, recurrenceEndDate?: string, recurrenceCount?: number, recurrenceInterval?: number, recurrenceDays?: number[]): string | null {
|
||||||
let freq: string;
|
let freq: string;
|
||||||
switch (recurrence) {
|
switch (recurrence) {
|
||||||
case 'daily': freq = 'FREQ=DAILY'; break;
|
case 'daily': freq = 'FREQ=DAILY'; break;
|
||||||
@ -71,10 +73,16 @@ function toRRule(recurrence?: string, recurrenceEndDate?: string, recurrenceCoun
|
|||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
let rrule = `RRULE:${freq}`;
|
let rrule = `RRULE:${freq}`;
|
||||||
|
if (recurrenceInterval && recurrenceInterval > 1 && recurrence !== 'biweekly') {
|
||||||
|
rrule += `;INTERVAL=${recurrenceInterval}`;
|
||||||
|
}
|
||||||
|
if (recurrenceDays && recurrenceDays.length > 0 && recurrence === 'weekly') {
|
||||||
|
const dayMap = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
|
||||||
|
rrule += `;BYDAY=${recurrenceDays.map(d => dayMap[d]).join(',')}`;
|
||||||
|
}
|
||||||
if (recurrenceCount && recurrenceCount > 0) {
|
if (recurrenceCount && recurrenceCount > 0) {
|
||||||
rrule += `;COUNT=${recurrenceCount}`;
|
rrule += `;COUNT=${recurrenceCount}`;
|
||||||
} else if (recurrenceEndDate) {
|
} else if (recurrenceEndDate) {
|
||||||
// UNTIL format: YYYYMMDDTHHMMSSZ
|
|
||||||
const d = new Date(recurrenceEndDate);
|
const d = new Date(recurrenceEndDate);
|
||||||
d.setHours(23, 59, 59);
|
d.setHours(23, 59, 59);
|
||||||
rrule += `;UNTIL=${d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`;
|
rrule += `;UNTIL=${d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`;
|
||||||
@ -85,9 +93,11 @@ function toRRule(recurrence?: string, recurrenceEndDate?: string, recurrenceCoun
|
|||||||
/**
|
/**
|
||||||
* Convert friendly recurrence name to Outlook Graph recurrence object
|
* Convert friendly recurrence name to Outlook Graph recurrence object
|
||||||
*/
|
*/
|
||||||
function toOutlookRecurrence(recurrence?: string, startDate?: Date, recurrenceEndDate?: string, recurrenceCount?: number): any {
|
function toOutlookRecurrence(recurrence?: string, startDate?: Date, recurrenceEndDate?: string, recurrenceCount?: number, recurrenceInterval?: number, recurrenceDays?: number[]): any {
|
||||||
if (!recurrence || recurrence === 'none') return undefined;
|
if (!recurrence || recurrence === 'none') return undefined;
|
||||||
const start = startDate || new Date();
|
const start = startDate || new Date();
|
||||||
|
const interval = recurrenceInterval || (recurrence === 'biweekly' ? 2 : 1);
|
||||||
|
const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
||||||
let range: any = {
|
let range: any = {
|
||||||
type: 'noEnd',
|
type: 'noEnd',
|
||||||
startDate: start.toISOString().split('T')[0],
|
startDate: start.toISOString().split('T')[0],
|
||||||
@ -99,15 +109,18 @@ function toOutlookRecurrence(recurrence?: string, startDate?: Date, recurrenceEn
|
|||||||
}
|
}
|
||||||
switch (recurrence) {
|
switch (recurrence) {
|
||||||
case 'daily':
|
case 'daily':
|
||||||
return { pattern: { type: 'daily', interval: 1 }, range };
|
return { pattern: { type: 'daily', interval }, range };
|
||||||
case 'weekly':
|
case 'weekly':
|
||||||
return { pattern: { type: 'weekly', interval: 1, daysOfWeek: [['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][start.getDay()]] }, range };
|
case 'biweekly': {
|
||||||
case 'biweekly':
|
const days = recurrenceDays && recurrenceDays.length > 0
|
||||||
return { pattern: { type: 'weekly', interval: 2, daysOfWeek: [['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][start.getDay()]] }, range };
|
? recurrenceDays.map(d => dayNames[d])
|
||||||
|
: [dayNames[start.getDay()]];
|
||||||
|
return { pattern: { type: 'weekly', interval, daysOfWeek: days }, range };
|
||||||
|
}
|
||||||
case 'monthly':
|
case 'monthly':
|
||||||
return { pattern: { type: 'absoluteMonthly', interval: 1, dayOfMonth: start.getDate() }, range };
|
return { pattern: { type: 'absoluteMonthly', interval, dayOfMonth: start.getDate() }, range };
|
||||||
case 'yearly':
|
case 'yearly':
|
||||||
return { pattern: { type: 'absoluteYearly', interval: 1, dayOfMonth: start.getDate(), month: start.getMonth() + 1 }, range };
|
return { pattern: { type: 'absoluteYearly', interval, dayOfMonth: start.getDate(), month: start.getMonth() + 1 }, range };
|
||||||
default: return undefined;
|
default: return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -516,6 +529,7 @@ export const getCalendarEvents = async (
|
|||||||
|
|
||||||
console.log(`[CALENDAR] Synology: fetched ${calendarEvents.length} events from calendar ${calendarId}`);
|
console.log(`[CALENDAR] Synology: fetched ${calendarEvents.length} events from calendar ${calendarId}`);
|
||||||
const calendarData = calendars.find(c => c.id === calendarId);
|
const calendarData = calendars.find(c => c.id === calendarId);
|
||||||
|
const freshCal = freshCalendars.find(c => c.id === calendarId);
|
||||||
|
|
||||||
events = events.concat(calendarEvents.map((event: any) => {
|
events = events.concat(calendarEvents.map((event: any) => {
|
||||||
const isDateOnly = (s: string) => s && !s.includes('T');
|
const isDateOnly = (s: string) => s && !s.includes('T');
|
||||||
@ -539,7 +553,7 @@ 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?.backgroundColor || calendarData?.color || freshCal?.color || '#1b85ff',
|
||||||
reminders: event.reminders as EventReminder[] || undefined,
|
reminders: event.reminders as EventReminder[] || undefined,
|
||||||
busyStatus: event.busyStatus as BusyStatus || undefined,
|
busyStatus: event.busyStatus as BusyStatus || undefined,
|
||||||
visibility: event.visibility as EventVisibility || undefined,
|
visibility: event.visibility as EventVisibility || undefined,
|
||||||
@ -825,7 +839,7 @@ export const createCalendarEvent = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Map to Google format
|
// Map to Google format
|
||||||
const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount);
|
const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays);
|
||||||
const googleEvent: any = {
|
const googleEvent: any = {
|
||||||
summary: event.title,
|
summary: event.title,
|
||||||
description: event.description,
|
description: event.description,
|
||||||
@ -877,7 +891,7 @@ export const createCalendarEvent = async (
|
|||||||
end: event.end,
|
end: event.end,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
allDay: event.allDay,
|
allDay: event.allDay,
|
||||||
recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount),
|
recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays),
|
||||||
reminders: event.reminders,
|
reminders: event.reminders,
|
||||||
busyStatus: event.busyStatus,
|
busyStatus: event.busyStatus,
|
||||||
visibility: event.visibility,
|
visibility: event.visibility,
|
||||||
@ -936,6 +950,8 @@ export const createCalendarEvent = async (
|
|||||||
recurrence: event.recurrence,
|
recurrence: event.recurrence,
|
||||||
recurrenceEndDate: event.recurrenceEndDate,
|
recurrenceEndDate: event.recurrenceEndDate,
|
||||||
recurrenceCount: event.recurrenceCount,
|
recurrenceCount: event.recurrenceCount,
|
||||||
|
recurrenceInterval: event.recurrenceInterval,
|
||||||
|
recurrenceDays: event.recurrenceDays,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
reminders: event.reminders,
|
reminders: event.reminders,
|
||||||
@ -974,6 +990,8 @@ export const createCalendarEvent = async (
|
|||||||
recurrence: event.recurrence,
|
recurrence: event.recurrence,
|
||||||
recurrenceEndDate: event.recurrenceEndDate,
|
recurrenceEndDate: event.recurrenceEndDate,
|
||||||
recurrenceCount: event.recurrenceCount,
|
recurrenceCount: event.recurrenceCount,
|
||||||
|
recurrenceInterval: event.recurrenceInterval,
|
||||||
|
recurrenceDays: event.recurrenceDays,
|
||||||
start: event.start!,
|
start: event.start!,
|
||||||
end: event.end!,
|
end: event.end!,
|
||||||
reminders: event.reminders,
|
reminders: event.reminders,
|
||||||
@ -1056,7 +1074,7 @@ export const updateCalendarEvent = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Map to Google format
|
// Map to Google format
|
||||||
const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount);
|
const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays);
|
||||||
const googleEvent: any = {};
|
const googleEvent: any = {};
|
||||||
if (event.title !== undefined) googleEvent.summary = event.title;
|
if (event.title !== undefined) googleEvent.summary = event.title;
|
||||||
if (event.description !== undefined) googleEvent.description = event.description;
|
if (event.description !== undefined) googleEvent.description = event.description;
|
||||||
@ -1109,7 +1127,7 @@ export const updateCalendarEvent = async (
|
|||||||
end: event.end,
|
end: event.end,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
allDay: event.allDay,
|
allDay: event.allDay,
|
||||||
recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount),
|
recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays),
|
||||||
reminders: event.reminders,
|
reminders: event.reminders,
|
||||||
busyStatus: event.busyStatus,
|
busyStatus: event.busyStatus,
|
||||||
visibility: event.visibility,
|
visibility: event.visibility,
|
||||||
|
|||||||
@ -402,6 +402,8 @@ export const createEvent = async (
|
|||||||
recurrence?: string;
|
recurrence?: string;
|
||||||
recurrenceEndDate?: string;
|
recurrenceEndDate?: string;
|
||||||
recurrenceCount?: number;
|
recurrenceCount?: number;
|
||||||
|
recurrenceInterval?: number;
|
||||||
|
recurrenceDays?: number[];
|
||||||
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 }>;
|
reminders?: Array<{ method: string; minutes: number }>;
|
||||||
@ -458,6 +460,13 @@ export const createEvent = async (
|
|||||||
};
|
};
|
||||||
if (rruleMap[eventData.recurrence]) {
|
if (rruleMap[eventData.recurrence]) {
|
||||||
let rrule = rruleMap[eventData.recurrence];
|
let rrule = rruleMap[eventData.recurrence];
|
||||||
|
if (eventData.recurrenceInterval && eventData.recurrenceInterval > 1 && eventData.recurrence !== 'biweekly') {
|
||||||
|
rrule += `;INTERVAL=${eventData.recurrenceInterval}`;
|
||||||
|
}
|
||||||
|
if (eventData.recurrenceDays && eventData.recurrenceDays.length > 0 && eventData.recurrence === 'weekly') {
|
||||||
|
const dayMap = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
|
||||||
|
rrule += `;BYDAY=${eventData.recurrenceDays.map(d => dayMap[d]).join(',')}`;
|
||||||
|
}
|
||||||
if (eventData.recurrenceCount && eventData.recurrenceCount > 0) {
|
if (eventData.recurrenceCount && eventData.recurrenceCount > 0) {
|
||||||
rrule += `;COUNT=${eventData.recurrenceCount}`;
|
rrule += `;COUNT=${eventData.recurrenceCount}`;
|
||||||
} else if (eventData.recurrenceEndDate) {
|
} else if (eventData.recurrenceEndDate) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user