feat: implement CalDAV discovery API and enhance calendar ID handling
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
interface DiscoveryAccountRequest {
|
||||
key: string;
|
||||
candidates: string[];
|
||||
}
|
||||
|
||||
interface DiscoveryResult {
|
||||
url: string | null;
|
||||
resolvedAccount: string | null;
|
||||
}
|
||||
|
||||
function buildPublicUrl(serverUrl: string, path: string): string {
|
||||
return new URL(path, serverUrl).toString();
|
||||
}
|
||||
|
||||
async function probeCalendarHome(serverUrl: string, authHeader: string, accountName: string): Promise<string | null> {
|
||||
const targetUrl = buildPublicUrl(serverUrl, `/dav/cal/${encodeURIComponent(accountName)}`);
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
Depth: '0',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
},
|
||||
body: `<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:displayname/>
|
||||
</D:prop>
|
||||
</D:propfind>`,
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
if (response.status === 207) {
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('Location');
|
||||
if (location) {
|
||||
return new URL(location, targetUrl).toString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const accounts = Array.isArray(body.accounts) ? body.accounts as DiscoveryAccountRequest[] : [];
|
||||
const wellKnownUrl = buildPublicUrl(creds.serverUrl, '/.well-known/caldav');
|
||||
const discovered: Record<string, DiscoveryResult> = {};
|
||||
|
||||
for (const account of accounts) {
|
||||
if (!account?.key) continue;
|
||||
|
||||
const candidates = Array.from(new Set(
|
||||
(account.candidates || [])
|
||||
.map((candidate) => candidate?.trim())
|
||||
.filter((candidate): candidate is string => Boolean(candidate))
|
||||
));
|
||||
|
||||
let url: string | null = null;
|
||||
let resolvedAccount: string | null = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
url = await probeCalendarHome(creds.serverUrl, creds.authHeader, candidate);
|
||||
if (url) {
|
||||
resolvedAccount = candidate;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('CalDAV discovery probe failed', {
|
||||
accountKey: account.key,
|
||||
candidate,
|
||||
error: error instanceof Error ? error.message : 'Unknown',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
discovered[account.key] = { url, resolvedAccount };
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
wellKnownUrl,
|
||||
accounts: discovered,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('CalDAV discovery failed', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { format, parseISO, isToday, isTomorrow } from "date-fns";
|
||||
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||
import { getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
|
||||
@@ -113,8 +113,8 @@ export function CalendarAgendaView({
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{group.events.map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
const calendar = calendarMap.get(calId);
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||
const color = getEventColor(ev, calendar);
|
||||
const start = parseISO(ev.start);
|
||||
const durMin = parseDuration(ev.duration);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { format, isToday, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import { QuickEventInput } from "./quick-event-input";
|
||||
import { getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
||||
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
|
||||
@@ -127,12 +127,12 @@ export function CalendarDayView({
|
||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||
<div className="space-y-1">
|
||||
{allDayEvents.map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
return (
|
||||
<EventCard
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||
variant="chip"
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
@@ -192,7 +192,7 @@ export function CalendarDayView({
|
||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const leftPct = (column / totalColumns) * 100;
|
||||
const widthPct = (1 / totalColumns) * 100;
|
||||
|
||||
@@ -205,7 +205,7 @@ export function CalendarDayView({
|
||||
>
|
||||
<EventCard
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||
variant="block"
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventCard } from "./event-card";
|
||||
import { buildWeekSegments, getEventDayBounds } from "@/lib/calendar-utils";
|
||||
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
@@ -197,8 +197,8 @@ export function CalendarMonthView({
|
||||
dayEvents.length > 0 && (
|
||||
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
||||
{dayEvents.slice(0, 3).map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
const cal = calendarMap.get(calId);
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const cal = calId ? calendarMap.get(calId) : undefined;
|
||||
const evColor = ev.color || cal?.color || "#3b82f6";
|
||||
return (
|
||||
<span
|
||||
@@ -222,7 +222,7 @@ export function CalendarMonthView({
|
||||
{!isMobile && segments.length > 0 && (
|
||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||
{segments.map((segment) => {
|
||||
const calId = Object.keys(segment.event.calendarIds)[0];
|
||||
const calId = getPrimaryCalendarId(segment.event);
|
||||
return (
|
||||
<div
|
||||
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
||||
@@ -236,7 +236,7 @@ export function CalendarMonthView({
|
||||
>
|
||||
<EventCard
|
||||
event={segment.event}
|
||||
calendar={calendarMap.get(calId)}
|
||||
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||
variant="span"
|
||||
continuesBefore={segment.continuesBefore}
|
||||
continuesAfter={segment.continuesAfter}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function CalendarSidebarPanel({
|
||||
const shared = calendars.filter(c => c.isShared);
|
||||
const groups = new Map<string, { accountName: string; calendars: Calendar[] }>();
|
||||
for (const cal of shared) {
|
||||
const key = cal.accountId!;
|
||||
const key = cal.accountId || cal.accountName || cal.id;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, { accountName: cal.accountName || key, calendars: [] });
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ export function CalendarToolbar({
|
||||
const shared = calendars.filter(c => c.isShared);
|
||||
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
|
||||
for (const c of shared) {
|
||||
const key = c.accountId!;
|
||||
const key = c.accountId || c.accountName || c.id;
|
||||
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
|
||||
groups.get(key)!.cals.push(c);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import { QuickEventInput } from "./quick-event-input";
|
||||
import { buildWeekSegments, getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
||||
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
|
||||
@@ -162,7 +162,7 @@ export function CalendarWeekView({
|
||||
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{allDaySegments.map((segment) => {
|
||||
const calId = Object.keys(segment.event.calendarIds)[0];
|
||||
const calId = getPrimaryCalendarId(segment.event);
|
||||
return (
|
||||
<div
|
||||
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
||||
@@ -176,7 +176,7 @@ export function CalendarWeekView({
|
||||
>
|
||||
<EventCard
|
||||
event={segment.event}
|
||||
calendar={calendarMap.get(calId)}
|
||||
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||
variant="span"
|
||||
continuesBefore={segment.continuesBefore}
|
||||
continuesAfter={segment.continuesAfter}
|
||||
@@ -284,7 +284,7 @@ export function CalendarWeekView({
|
||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const leftPct = (column / totalColumns) * 100;
|
||||
const widthPct = (1 / totalColumns) * 100;
|
||||
|
||||
@@ -297,7 +297,7 @@ export function CalendarWeekView({
|
||||
>
|
||||
<EventCard
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||
variant="block"
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid
|
||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate } from "@/lib/calendar-utils";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { ParticipantInput } from "./participant-input";
|
||||
import {
|
||||
isOrganizer,
|
||||
@@ -184,7 +184,7 @@ export function EventModal({
|
||||
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||
if (event?.calendarIds) return Object.keys(event.calendarIds)[0] || calendars[0]?.id || "";
|
||||
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
||||
const defaultCal = calendars.find(c => c.isDefault);
|
||||
return defaultCal?.id || calendars[0]?.id || "";
|
||||
});
|
||||
|
||||
@@ -153,6 +153,9 @@ export function CalendarManagementSettings() {
|
||||
const { client, serverUrl, username } = useAuthStore();
|
||||
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
||||
|
||||
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
|
||||
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
@@ -175,6 +178,60 @@ export function CalendarManagementSettings() {
|
||||
}
|
||||
}, [client, calendars.length, fetchCalendars]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !serverUrl || !username) {
|
||||
setDiscoveredCalDavUrls({});
|
||||
setWellKnownCalDavUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryKey = username;
|
||||
const accounts = new Map<string, string[]>();
|
||||
accounts.set(primaryKey, [username]);
|
||||
|
||||
for (const calendar of calendars) {
|
||||
if (!calendar.isShared) continue;
|
||||
const key = calendar.accountId || calendar.accountName || calendar.id;
|
||||
const candidates = accounts.get(key) || [];
|
||||
if (calendar.accountId) candidates.push(calendar.accountId);
|
||||
if (calendar.accountName) candidates.push(calendar.accountName);
|
||||
accounts.set(key, candidates);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
fetch('/api/caldav/discover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`CalDAV discovery failed: ${response.status}`);
|
||||
return response.json() as Promise<{
|
||||
wellKnownUrl?: string;
|
||||
accounts?: Record<string, { url: string | null }>;
|
||||
}>;
|
||||
})
|
||||
.then((payload) => {
|
||||
setWellKnownCalDavUrl(payload.wellKnownUrl || null);
|
||||
const next: Record<string, string | null> = {};
|
||||
for (const [key, value] of Object.entries(payload.accounts || {})) {
|
||||
next[key] = value?.url || null;
|
||||
}
|
||||
setDiscoveredCalDavUrls(next);
|
||||
})
|
||||
.catch(() => {
|
||||
const fallbackWellKnown = new URL('/.well-known/caldav', serverUrl).toString();
|
||||
setDiscoveredCalDavUrls({});
|
||||
setWellKnownCalDavUrl(fallbackWellKnown);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [client, calendars, serverUrl, username]);
|
||||
|
||||
const handleRefreshSubscription = async (subId: string) => {
|
||||
if (!client) return;
|
||||
setRefreshingSubId(subId);
|
||||
@@ -295,8 +352,10 @@ export function CalendarManagementSettings() {
|
||||
|
||||
const buildCalDavUrl = (calendarId: string) => {
|
||||
if (!serverUrl || !username) return null;
|
||||
const base = serverUrl.replace(/\/$/, '');
|
||||
return `${base}/dav/cal/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`;
|
||||
const calendar = calendars.find((entry) => entry.id === calendarId);
|
||||
if (!calendar) return null;
|
||||
const accountKey = calendar.isShared ? (calendar.accountId || calendar.accountName || calendar.id) : username;
|
||||
return discoveredCalDavUrls[accountKey] || wellKnownCalDavUrl;
|
||||
};
|
||||
|
||||
const handleCopyUrl = async (url: string) => {
|
||||
|
||||
@@ -142,3 +142,7 @@ export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): stri
|
||||
}
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function getPrimaryCalendarId(event: Pick<CalendarEvent, 'calendarIds'>): string | undefined {
|
||||
return Object.keys(event.calendarIds || {})[0];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user