import { parseISO } from 'date-fns'; import type { CalendarEvent } from '@/lib/jmap/types'; const DURATION_RE = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; function isDateOnlyValue(value: string): boolean { return /^\d{4}-\d{2}-\d{2}$/.test(value); } function isMidnightValue(value: string): boolean { if (isDateOnlyValue(value)) { return true; } const match = value.match(/T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?$/); if (!match) { return false; } return match[1] === '00' && match[2] === '00' && (match[3] ?? '00') === '00'; } function parseDurationSeconds(duration: string | undefined): number | null { if (!duration) { return null; } const match = DURATION_RE.exec(duration); if (!match) { return null; } const weeks = parseInt(match[1] || '0', 10); const days = parseInt(match[2] || '0', 10); const hours = parseInt(match[3] || '0', 10); const minutes = parseInt(match[4] || '0', 10); const seconds = parseInt(match[5] || '0', 10); return (((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds; } export function normalizeAllDayDurationValue(duration: string | undefined): string | undefined { const totalSeconds = parseDurationSeconds(duration); if (totalSeconds === null || totalSeconds < 86400 || totalSeconds % 86400 !== 0) { return duration; } return `P${totalSeconds / 86400}D`; } export function isAllDayEventLike(event: Pick, 'start' | 'duration' | 'showWithoutTime'>): boolean { if (event.showWithoutTime) { return true; } if (!event.start || !event.duration || !isMidnightValue(event.start)) { return false; } const totalSeconds = parseDurationSeconds(event.duration); if (totalSeconds === null || totalSeconds < 86400 || totalSeconds % 86400 !== 0) { return false; } const start = parseISO(event.start); if (Number.isNaN(start.getTime())) { return false; } const end = new Date(start.getTime() + totalSeconds * 1000); return end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0 && end.getMilliseconds() === 0; } /** * Stalwart returns "recurrenceRule" (singular) instead of RFC 8984 "recurrenceRules" (plural). * Normalize server responses to match the client's internal type. */ function normalizeStalwartPropertyNames>(event: T): T { const raw = event as Record; let patched = false; const updates: Partial = {}; if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) { // JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object, // but Stalwart may also return it as an array (for JMAP-created events). // Normalize both forms to our internal array type. const val = raw.recurrenceRule; if (val != null && !Array.isArray(val) && typeof val === 'object') { updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules']; } else { updates.recurrenceRules = val as CalendarEvent['recurrenceRules']; } patched = true; } if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) { const val = raw.excludedRecurrenceRule; if (val != null && !Array.isArray(val) && typeof val === 'object') { updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules']; } else { updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules']; } patched = true; } if (!patched) return event; const result = { ...event, ...updates } as T; delete (result as Record).recurrenceRule; delete (result as Record).excludedRecurrenceRule; return result; } export function normalizeCalendarEventLike>(event: T): T { // First normalize Stalwart's singular property names to RFC 8984 plural forms const normalized = normalizeStalwartPropertyNames(event); if (!isAllDayEventLike(normalized)) { return normalized; } return { ...normalized, showWithoutTime: true, duration: normalizeAllDayDurationValue(normalized.duration), } as T; } export function sanitizeOutgoingCalendarEventData>(event: T): T { const normalized = normalizeCalendarEventLike(event); if (!normalized.showWithoutTime) { return normalized; } const normalizedStart = normalized.start ? `${normalized.start.slice(0, 10)}T00:00:00` : normalized.start; return { ...normalized, start: normalizedStart, duration: normalizeAllDayDurationValue(normalized.duration), timeZone: null, } as T; }