refactor: fix bugs in calendar logic across duration parsing, RFC compliance, and event handling
- Fix buildDuration() trailing "T" producing invalid ISO 8601 durations - Fix DURATION_RE missing week (W) support in alerts and invitation parsing - Fix computeFireTime() end fallback when utcEnd is missing - Fix recurrenceOverrides patch escaping per RFC 6901 (updateEvent/rsvpEvent) - Fix layoutOverlappingEvents endMin overflow past 1440 - Fix addDurationToDate() to support weeks and use UTC methods for UTC inputs - Fix getEffectiveAlerts() null guard on calendarIds - Fix buildAllDayDuration() DST-safe day calculation using differenceInCalendarDays - Fix participant matching to check calendarAddress and sendTo (not just email) - Fix buildParticipantMap() using crypto.randomUUID() instead of hardcoded IDs - Fix overnight preview negative endMin in week view - Fix sendImipInvitation() to emit DURATION when utcEnd is absent - Fix sendImipCancellation() to validate status before sending - Fix createEvent() to remap all calendarIds for shared calendars - Fix getCalendarTasks() to clone before mutating @type - Fix importEvents() error matching to include 'duplicate' and 'conflict' - Fix looksLikeReply() false positive by requiring organizer + responded attendee - Fix alert offset regex to require T before minutes - Fix handleDuplicate() to generate new UID - Fix formatSnapTime() input clamping - Replace console.log/error with debug.log/error/warn in iMIP functions
This commit is contained in:
@@ -448,7 +448,8 @@ export function CalendarWeekView({
|
|||||||
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
|
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
|
||||||
(() => {
|
(() => {
|
||||||
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
|
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
|
||||||
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
|
let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
|
||||||
|
if (endMin <= startMin) endMin = 1440;
|
||||||
const durationMin = Math.max(15, endMin - startMin);
|
const durationMin = Math.max(15, endMin - startMin);
|
||||||
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||||
const color = cal?.color || "hsl(var(--primary))";
|
const color = cal?.color || "hsl(var(--primary))";
|
||||||
|
|||||||
@@ -59,10 +59,12 @@ function buildDuration(startDate: Date, endDate: Date): string {
|
|||||||
const minutes = totalMinutes % 60;
|
const minutes = totalMinutes % 60;
|
||||||
let dur = "P";
|
let dur = "P";
|
||||||
if (days > 0) dur += `${days}D`;
|
if (days > 0) dur += `${days}D`;
|
||||||
dur += "T";
|
if (hours > 0 || minutes > 0) {
|
||||||
if (hours > 0) dur += `${hours}H`;
|
dur += "T";
|
||||||
if (minutes > 0) dur += `${minutes}M`;
|
if (hours > 0) dur += `${hours}H`;
|
||||||
if (dur === "PT") dur = "PT0M";
|
if (minutes > 0) dur += `${minutes}M`;
|
||||||
|
}
|
||||||
|
if (dur === "P") dur = "PT0M";
|
||||||
return dur;
|
return dur;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +85,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
|
|||||||
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
||||||
const offset = first.trigger.offset;
|
const offset = first.trigger.offset;
|
||||||
if (offset === "PT0S") return t("alerts.at_time");
|
if (offset === "PT0S") return t("alerts.at_time");
|
||||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
const minMatch = offset.match(/-?PT(\d+)M$/);
|
||||||
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
|
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
|
||||||
const hourMatch = offset.match(/-?PT?(\d+)H$/);
|
const hourMatch = offset.match(/-?PT(\d+)H$/);
|
||||||
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
|
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
|
||||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
const dayMatch = offset.match(/-?P(\d+)D/);
|
||||||
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
|
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
|
||||||
@@ -209,9 +211,9 @@ export function EventModal({
|
|||||||
if (first.trigger["@type"] === "OffsetTrigger") {
|
if (first.trigger["@type"] === "OffsetTrigger") {
|
||||||
const offset = first.trigger.offset;
|
const offset = first.trigger.offset;
|
||||||
if (offset === "PT0S") return "at_time";
|
if (offset === "PT0S") return "at_time";
|
||||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
const minMatch = offset.match(/-?PT(\d+)M$/);
|
||||||
if (minMatch) return minMatch[1] as AlertOption;
|
if (minMatch) return minMatch[1] as AlertOption;
|
||||||
const hourMatch = offset.match(/-?PT?(\d+)H$/);
|
const hourMatch = offset.match(/-?PT(\d+)H$/);
|
||||||
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
|
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
|
||||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
const dayMatch = offset.match(/-?P(\d+)D/);
|
||||||
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
|
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
|
||||||
@@ -384,7 +386,11 @@ export function EventModal({
|
|||||||
if (!event || !onDuplicate) return;
|
if (!event || !onDuplicate) return;
|
||||||
const start = parseISO(event.start);
|
const start = parseISO(event.start);
|
||||||
const newStart = addDays(start, 1);
|
const newStart = addDays(start, 1);
|
||||||
|
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
const data: Partial<CalendarEvent> = {
|
const data: Partial<CalendarEvent> = {
|
||||||
|
uid: newUid,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
description: event.description,
|
description: event.description,
|
||||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||||
|
|||||||
+18
-9
@@ -6,6 +6,7 @@ import type {
|
|||||||
Calendar,
|
Calendar,
|
||||||
CalendarTask,
|
CalendarTask,
|
||||||
} from '@/lib/jmap/types';
|
} from '@/lib/jmap/types';
|
||||||
|
import { parseDuration } from '@/components/calendar/event-card';
|
||||||
|
|
||||||
export interface PendingAlert {
|
export interface PendingAlert {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
@@ -17,19 +18,20 @@ export interface PendingAlert {
|
|||||||
|
|
||||||
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
const DURATION_RE = /^(-?)P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
|
const DURATION_RE = /^(-?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
|
||||||
|
|
||||||
export function parseAlertOffset(offset: string): number | null {
|
export function parseAlertOffset(offset: string): number | null {
|
||||||
const match = DURATION_RE.exec(offset);
|
const match = DURATION_RE.exec(offset);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
const negative = match[1] === '-';
|
const negative = match[1] === '-';
|
||||||
const days = parseInt(match[2] || '0', 10);
|
const weeks = parseInt(match[2] || '0', 10);
|
||||||
const hours = parseInt(match[3] || '0', 10);
|
const days = parseInt(match[3] || '0', 10);
|
||||||
const minutes = parseInt(match[4] || '0', 10);
|
const hours = parseInt(match[4] || '0', 10);
|
||||||
const seconds = parseInt(match[5] || '0', 10);
|
const minutes = parseInt(match[5] || '0', 10);
|
||||||
|
const seconds = parseInt(match[6] || '0', 10);
|
||||||
|
|
||||||
const ms = ((days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000;
|
const ms = ((weeks * 7 * 24 * 60 * 60) + (days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000;
|
||||||
return negative ? -ms : ms;
|
return negative ? -ms : ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,9 +49,15 @@ export function computeFireTime(
|
|||||||
|
|
||||||
let baseTime: number;
|
let baseTime: number;
|
||||||
if (trigger.relativeTo === 'end') {
|
if (trigger.relativeTo === 'end') {
|
||||||
baseTime = event.utcEnd
|
if (event.utcEnd) {
|
||||||
? new Date(event.utcEnd).getTime()
|
baseTime = new Date(event.utcEnd).getTime();
|
||||||
: new Date(event.start).getTime();
|
} else {
|
||||||
|
// Compute end from start + duration
|
||||||
|
const startMs = new Date(event.start).getTime();
|
||||||
|
if (Number.isNaN(startMs)) return null;
|
||||||
|
const durationMin = parseDuration(event.duration);
|
||||||
|
baseTime = startMs + durationMin * 60000;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
baseTime = event.utcStart
|
baseTime = event.utcStart
|
||||||
? new Date(event.utcStart).getTime()
|
? new Date(event.utcStart).getTime()
|
||||||
@@ -68,6 +76,7 @@ export function getEffectiveAlerts(
|
|||||||
return event.alerts;
|
return event.alerts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!event.calendarIds) return null;
|
||||||
const calendarId = Object.keys(event.calendarIds)[0];
|
const calendarId = Object.keys(event.calendarIds)[0];
|
||||||
if (!calendarId) return null;
|
if (!calendarId) return null;
|
||||||
|
|
||||||
|
|||||||
+29
-24
@@ -251,10 +251,11 @@ function looksLikeReply(event: Partial<CalendarEvent>): boolean {
|
|||||||
|
|
||||||
const participants = Object.values(event.participants);
|
const participants = Object.values(event.participants);
|
||||||
const hasOrganizer = participants.some((participant) => isOrganizerParticipant(participant));
|
const hasOrganizer = participants.some((participant) => isOrganizerParticipant(participant));
|
||||||
if (hasOrganizer) return false;
|
if (!hasOrganizer) return false;
|
||||||
|
|
||||||
return participants.some((participant) =>
|
return participants.some((participant) =>
|
||||||
participant.roles?.attendee
|
participant.roles?.attendee
|
||||||
|
&& !isOrganizerParticipant(participant)
|
||||||
&& (
|
&& (
|
||||||
participant.participationStatus !== 'needs-action'
|
participant.participationStatus !== 'needs-action'
|
||||||
|| !!participant.participationComment
|
|| !!participant.participationComment
|
||||||
@@ -373,6 +374,10 @@ export function getInvitationMethod(
|
|||||||
return 'cancel';
|
return 'cancel';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (looksLikeReply(event)) {
|
||||||
|
return 'reply';
|
||||||
|
}
|
||||||
|
|
||||||
if (event.participants && Object.keys(event.participants).length > 0) {
|
if (event.participants && Object.keys(event.participants).length > 0) {
|
||||||
const hasOrganizer = Object.values(event.participants).some(
|
const hasOrganizer = Object.values(event.participants).some(
|
||||||
(p: CalendarParticipant) => isOrganizerParticipant(p)
|
(p: CalendarParticipant) => isOrganizerParticipant(p)
|
||||||
@@ -382,10 +387,6 @@ export function getInvitationMethod(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (looksLikeReply(event)) {
|
|
||||||
return 'reply';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,36 +525,40 @@ export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addDurationToDate(start: string, duration: string, _timeZone?: string | null): string | null {
|
function addDurationToDate(start: string, duration: string, _timeZone?: string | null): string | null {
|
||||||
const match = duration.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
|
const match = duration.match(/^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
const days = parseInt(match[1] || '0');
|
const weeks = parseInt(match[1] || '0');
|
||||||
const hours = parseInt(match[2] || '0');
|
const days = parseInt(match[2] || '0') + weeks * 7;
|
||||||
const minutes = parseInt(match[3] || '0');
|
const hours = parseInt(match[3] || '0');
|
||||||
const seconds = parseInt(match[4] || '0');
|
const minutes = parseInt(match[4] || '0');
|
||||||
|
const seconds = parseInt(match[5] || '0');
|
||||||
|
|
||||||
const date = new Date(start);
|
const date = new Date(start);
|
||||||
if (isNaN(date.getTime())) return null;
|
if (isNaN(date.getTime())) return null;
|
||||||
|
|
||||||
|
const isUTC = start.endsWith('Z') || start.includes('+');
|
||||||
|
|
||||||
|
if (isUTC) {
|
||||||
|
date.setUTCDate(date.getUTCDate() + days);
|
||||||
|
date.setUTCHours(date.getUTCHours() + hours);
|
||||||
|
date.setUTCMinutes(date.getUTCMinutes() + minutes);
|
||||||
|
date.setUTCSeconds(date.getUTCSeconds() + seconds);
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
date.setDate(date.getDate() + days);
|
date.setDate(date.getDate() + days);
|
||||||
date.setHours(date.getHours() + hours);
|
date.setHours(date.getHours() + hours);
|
||||||
date.setMinutes(date.getMinutes() + minutes);
|
date.setMinutes(date.getMinutes() + minutes);
|
||||||
date.setSeconds(date.getSeconds() + seconds);
|
date.setSeconds(date.getSeconds() + seconds);
|
||||||
|
|
||||||
// If the input is a local datetime (no UTC 'Z' suffix), return a local
|
const y = date.getFullYear();
|
||||||
// format string so that all-day date arithmetic isn't shifted by the
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
// browser's UTC offset (toISOString converts to UTC).
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
if (!start.endsWith('Z') && !start.includes('+')) {
|
const h = String(date.getHours()).padStart(2, '0');
|
||||||
const y = date.getFullYear();
|
const min = String(date.getMinutes()).padStart(2, '0');
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
const s = String(date.getSeconds()).padStart(2, '0');
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
return `${y}-${m}-${d}T${h}:${min}:${s}`;
|
||||||
const h = String(date.getHours()).padStart(2, '0');
|
|
||||||
const min = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
const s = String(date.getSeconds()).padStart(2, '0');
|
|
||||||
return `${y}-${m}-${d}T${h}:${min}:${s}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toISOString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findParticipantByEmail(
|
export function findParticipantByEmail(
|
||||||
|
|||||||
@@ -15,11 +15,30 @@ export interface StatusCounts {
|
|||||||
'needs-action': number;
|
'needs-action': number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a participant matches any of the given email addresses.
|
||||||
|
* Checks p.email, p.calendarAddress (mailto:...), and p.sendTo values.
|
||||||
|
*/
|
||||||
|
function participantMatchesEmail(p: CalendarParticipant, lowerEmails: string[]): boolean {
|
||||||
|
if (p.email && lowerEmails.includes(p.email.toLowerCase())) return true;
|
||||||
|
if (p.calendarAddress) {
|
||||||
|
const addr = p.calendarAddress.replace(/^mailto:/i, '').toLowerCase();
|
||||||
|
if (addr && lowerEmails.includes(addr)) return true;
|
||||||
|
}
|
||||||
|
if (p.sendTo) {
|
||||||
|
for (const addr of Object.values(p.sendTo)) {
|
||||||
|
const normalized = addr.replace(/^mailto:/i, '').toLowerCase();
|
||||||
|
if (normalized && lowerEmails.includes(normalized)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function isOrganizer(event: CalendarEvent, userEmails: string[]): boolean {
|
export function isOrganizer(event: CalendarEvent, userEmails: string[]): boolean {
|
||||||
if (!event.participants) return false;
|
if (!event.participants) return false;
|
||||||
const lower = userEmails.map(e => e.toLowerCase());
|
const lower = userEmails.map(e => e.toLowerCase());
|
||||||
return Object.values(event.participants).some(p =>
|
return Object.values(event.participants).some(p =>
|
||||||
p.roles?.owner && lower.includes(p.email?.toLowerCase())
|
p.roles?.owner && participantMatchesEmail(p, lower)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +46,7 @@ export function getUserParticipantId(event: CalendarEvent, userEmails: string[])
|
|||||||
if (!event.participants) return null;
|
if (!event.participants) return null;
|
||||||
const lower = userEmails.map(e => e.toLowerCase());
|
const lower = userEmails.map(e => e.toLowerCase());
|
||||||
for (const [id, p] of Object.entries(event.participants)) {
|
for (const [id, p] of Object.entries(event.participants)) {
|
||||||
if (lower.includes(p.email?.toLowerCase())) return id;
|
if (participantMatchesEmail(p, lower)) return id;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -39,20 +58,29 @@ export function getUserStatus(
|
|||||||
if (!event.participants) return null;
|
if (!event.participants) return null;
|
||||||
const lower = userEmails.map(e => e.toLowerCase());
|
const lower = userEmails.map(e => e.toLowerCase());
|
||||||
for (const p of Object.values(event.participants)) {
|
for (const p of Object.values(event.participants)) {
|
||||||
if (lower.includes(p.email?.toLowerCase())) return p.participationStatus;
|
if (participantMatchesEmail(p, lower)) return p.participationStatus;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getParticipantList(event: CalendarEvent): ParticipantInfo[] {
|
export function getParticipantList(event: CalendarEvent): ParticipantInfo[] {
|
||||||
if (!event.participants) return [];
|
if (!event.participants) return [];
|
||||||
return Object.entries(event.participants).map(([id, p]) => ({
|
return Object.entries(event.participants).map(([id, p]) => {
|
||||||
id,
|
let email = p.email || '';
|
||||||
name: p.name || '',
|
if (!email && p.calendarAddress) {
|
||||||
email: p.email || '',
|
email = p.calendarAddress.replace(/^mailto:/i, '');
|
||||||
status: p.participationStatus || 'needs-action',
|
}
|
||||||
isOrganizer: !!p.roles?.owner,
|
if (!email && p.sendTo?.imip) {
|
||||||
}));
|
email = p.sendTo.imip.replace(/^mailto:/i, '');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: p.name || '',
|
||||||
|
email,
|
||||||
|
status: p.participationStatus || 'needs-action',
|
||||||
|
isOrganizer: !!p.roles?.owner,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStatusCounts(event: CalendarEvent): StatusCounts {
|
export function getStatusCounts(event: CalendarEvent): StatusCounts {
|
||||||
@@ -76,7 +104,11 @@ export function buildParticipantMap(
|
|||||||
): Record<string, Partial<CalendarParticipant>> {
|
): Record<string, Partial<CalendarParticipant>> {
|
||||||
const participants: Record<string, Partial<CalendarParticipant>> = {};
|
const participants: Record<string, Partial<CalendarParticipant>> = {};
|
||||||
|
|
||||||
participants['organizer'] = {
|
const generateId = () => typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `p-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
|
participants[generateId()] = {
|
||||||
'@type': 'Participant',
|
'@type': 'Participant',
|
||||||
name: organizer.name,
|
name: organizer.name,
|
||||||
email: organizer.email,
|
email: organizer.email,
|
||||||
@@ -88,8 +120,8 @@ export function buildParticipantMap(
|
|||||||
kind: 'individual',
|
kind: 'individual',
|
||||||
};
|
};
|
||||||
|
|
||||||
attendees.forEach((a, i) => {
|
attendees.forEach((a) => {
|
||||||
participants[`attendee-${i}`] = {
|
participants[generateId()] = {
|
||||||
'@type': 'Participant',
|
'@type': 'Participant',
|
||||||
name: a.name,
|
name: a.name,
|
||||||
email: a.email,
|
email: a.email,
|
||||||
|
|||||||
@@ -40,9 +40,7 @@ export function normalizeAllDayDuration(duration: string | undefined): string |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string {
|
export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string {
|
||||||
const startDay = startOfDay(start);
|
const dayCount = Math.max(1, differenceInCalendarDays(startOfDay(inclusiveEnd), startOfDay(start)) + 1);
|
||||||
const endDay = startOfDay(inclusiveEnd);
|
|
||||||
const dayCount = Math.max(1, Math.round((endDay.getTime() - startDay.getTime()) / 86400000) + 1);
|
|
||||||
return `P${dayCount}D`;
|
return `P${dayCount}D`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +111,7 @@ export function layoutOverlappingEvents(
|
|||||||
for (const event of sorted) {
|
for (const event of sorted) {
|
||||||
const start = parseISO(event.start);
|
const start = parseISO(event.start);
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||||
const endMin = startMin + Math.max(15, parseDuration(event.duration));
|
const endMin = Math.min(1440, startMin + Math.max(15, parseDuration(event.duration)));
|
||||||
let placed = false;
|
let placed = false;
|
||||||
for (let col = 0; col < columns.length; col++) {
|
for (let col = 0; col < columns.length; col++) {
|
||||||
if (columns[col].every(e => e.end <= startMin)) {
|
if (columns[col].every(e => e.end <= startMin)) {
|
||||||
@@ -135,8 +133,9 @@ export function layoutOverlappingEvents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): string {
|
export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): string {
|
||||||
const h = Math.floor(minutes / 60);
|
const clamped = Math.max(0, Math.min(1440, minutes));
|
||||||
const m = minutes % 60;
|
const h = Math.floor(clamped / 60) % 24;
|
||||||
|
const m = clamped % 60;
|
||||||
if (timeFormat === "12h") {
|
if (timeFormat === "12h") {
|
||||||
return `${h % 12 || 12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`;
|
return `${h % 12 || 12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-9
@@ -2,6 +2,7 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, Emai
|
|||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
import type { IJMAPClient } from "./client-interface";
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
import { toWildcardQuery } from "./search-utils";
|
||||||
|
import { debug } from "@/lib/debug";
|
||||||
|
|
||||||
// JMAP protocol types - these are intentionally flexible due to server variations
|
// JMAP protocol types - these are intentionally flexible due to server variations
|
||||||
interface JMAPSession {
|
interface JMAPSession {
|
||||||
@@ -1684,7 +1685,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
lines.push('END:VCALENDAR');
|
lines.push('END:VCALENDAR');
|
||||||
const icsContent = lines.join('\r\n') + '\r\n';
|
const icsContent = lines.join('\r\n') + '\r\n';
|
||||||
|
|
||||||
console.log('[iMIP DEBUG] Generated ICS:\n' + icsContent);
|
debug.log('[iMIP] Generated ICS:\n' + icsContent);
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
ACCEPTED: 'Accepted',
|
ACCEPTED: 'Accepted',
|
||||||
@@ -1694,7 +1695,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const statusLabel = statusLabels[opts.status] || opts.status;
|
const statusLabel = statusLabels[opts.status] || opts.status;
|
||||||
const subject = `${statusLabel}: ${opts.summary || 'Event'}`;
|
const subject = `${statusLabel}: ${opts.summary || 'Event'}`;
|
||||||
|
|
||||||
console.log('[iMIP DEBUG] identityId:', finalIdentityId);
|
debug.log('[iMIP] identityId:', finalIdentityId);
|
||||||
|
|
||||||
const emailId = `imip-reply-${Date.now()}`;
|
const emailId = `imip-reply-${Date.now()}`;
|
||||||
const emailCreate: Record<string, unknown> = {
|
const emailCreate: Record<string, unknown> = {
|
||||||
@@ -1727,27 +1728,27 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}, "1"],
|
}, "1"],
|
||||||
];
|
];
|
||||||
|
|
||||||
console.log('[iMIP DEBUG] Sending JMAP request with', methodCalls.length, 'method calls');
|
debug.log('[iMIP] Sending JMAP request with', methodCalls.length, 'method calls');
|
||||||
console.log('[iMIP DEBUG] Email create payload:', JSON.stringify(emailCreate, null, 2));
|
debug.log('[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2));
|
||||||
|
|
||||||
const response = await this.request(methodCalls);
|
const response = await this.request(methodCalls);
|
||||||
|
|
||||||
console.log('[iMIP DEBUG] JMAP response:', JSON.stringify(response.methodResponses, null, 2));
|
debug.log('[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2));
|
||||||
|
|
||||||
if (response.methodResponses) {
|
if (response.methodResponses) {
|
||||||
for (const [methodName, result] of response.methodResponses) {
|
for (const [methodName, result] of response.methodResponses) {
|
||||||
if (methodName.endsWith('/error')) {
|
if (methodName.endsWith('/error')) {
|
||||||
console.error('[iMIP DEBUG] method error:', methodName, result);
|
debug.error('[iMIP] method error:', methodName, result);
|
||||||
throw new Error(result.description || `iMIP reply failed: ${result.type}`);
|
throw new Error(result.description || `iMIP reply failed: ${result.type}`);
|
||||||
}
|
}
|
||||||
if (result.notCreated) {
|
if (result.notCreated) {
|
||||||
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
|
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
|
||||||
console.error('[iMIP DEBUG] create error:', JSON.stringify(result.notCreated, null, 2));
|
debug.error('[iMIP] create error:', JSON.stringify(result.notCreated, null, 2));
|
||||||
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP reply');
|
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP reply');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('[iMIP DEBUG] sendImipReply completed successfully');
|
debug.log('[iMIP] sendImipReply completed successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1823,6 +1824,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const formatted = formatIcalDate(event.utcEnd, event.timeZone);
|
const formatted = formatIcalDate(event.utcEnd, event.timeZone);
|
||||||
lines.push(formatted.startsWith('TZID=') ? `DTEND;${formatted}` : `DTEND:${formatted}`);
|
lines.push(formatted.startsWith('TZID=') ? `DTEND;${formatted}` : `DTEND:${formatted}`);
|
||||||
}
|
}
|
||||||
|
} else if (event.duration) {
|
||||||
|
// Fallback: emit DURATION when utcEnd is absent (RFC 5545 §3.6.1)
|
||||||
|
lines.push(`DURATION:${event.duration}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.title) lines.push(`SUMMARY:${event.title}`);
|
if (event.title) lines.push(`SUMMARY:${event.title}`);
|
||||||
@@ -1907,6 +1911,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
*/
|
*/
|
||||||
async sendImipCancellation(event: CalendarEvent): Promise<void> {
|
async sendImipCancellation(event: CalendarEvent): Promise<void> {
|
||||||
if (!event.participants) return;
|
if (!event.participants) return;
|
||||||
|
if (event.status && event.status !== 'cancelled') {
|
||||||
|
debug.warn('sendImipCancellation called on non-cancelled event, status:', event.status);
|
||||||
|
}
|
||||||
|
|
||||||
const mailboxes = await this.getMailboxes();
|
const mailboxes = await this.getMailboxes();
|
||||||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||||
@@ -3202,7 +3209,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (type !== 'Event' && 'progress' in obj && typeof obj.progress === 'string') return true;
|
if (type !== 'Event' && 'progress' in obj && typeof obj.progress === 'string') return true;
|
||||||
return false;
|
return false;
|
||||||
}).map((e) => {
|
}).map((e) => {
|
||||||
const task = e as unknown as CalendarTask;
|
const task = { ...e } as unknown as CalendarTask;
|
||||||
// Normalize @type for tasks detected by fallback heuristic
|
// Normalize @type for tasks detected by fallback heuristic
|
||||||
if (task['@type'] !== 'Task') {
|
if (task['@type'] !== 'Task') {
|
||||||
(task as unknown as Record<string, unknown>)['@type'] = 'Task';
|
(task as unknown as Record<string, unknown>)['@type'] = 'Task';
|
||||||
|
|||||||
+24
-10
@@ -130,14 +130,17 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
let targetAccountId = event.accountId;
|
let targetAccountId = event.accountId;
|
||||||
const cleanEvent = { ...event };
|
const cleanEvent = { ...event };
|
||||||
if (event.calendarIds) {
|
if (event.calendarIds) {
|
||||||
const calId = Object.keys(event.calendarIds)[0];
|
const remapped: Record<string, boolean> = {};
|
||||||
if (calId) {
|
for (const calId of Object.keys(event.calendarIds)) {
|
||||||
const cal = get().calendars.find(c => c.id === calId);
|
const cal = get().calendars.find(c => c.id === calId);
|
||||||
if (cal?.isShared && cal.originalId) {
|
if (cal?.isShared && cal.originalId) {
|
||||||
targetAccountId = cal.accountId;
|
targetAccountId = cal.accountId;
|
||||||
cleanEvent.calendarIds = { [cal.originalId]: true };
|
remapped[cal.originalId] = true;
|
||||||
|
} else {
|
||||||
|
remapped[calId] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cleanEvent.calendarIds = remapped;
|
||||||
}
|
}
|
||||||
if (event.originalCalendarIds) {
|
if (event.originalCalendarIds) {
|
||||||
cleanEvent.calendarIds = event.originalCalendarIds;
|
cleanEvent.calendarIds = event.originalCalendarIds;
|
||||||
@@ -185,12 +188,18 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
if (realEvent) {
|
if (realEvent) {
|
||||||
const resolvedId = realEvent.originalId || realEvent.id;
|
const resolvedId = realEvent.originalId || realEvent.id;
|
||||||
if (storeEvent.recurrenceId) {
|
if (storeEvent.recurrenceId) {
|
||||||
// Recurring instance: patch the master event's recurrenceOverrides
|
// Recurring instance: patch the master event's recurrenceOverrides.
|
||||||
const patchUpdates: Record<string, unknown> = {};
|
// Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1
|
||||||
|
const escapedRecurrenceId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||||
|
// Build override object with all changed properties
|
||||||
|
const overrideObj: Record<string, unknown> = {};
|
||||||
for (const [key, value] of Object.entries(cleanUpdates as Record<string, unknown>)) {
|
for (const [key, value] of Object.entries(cleanUpdates as Record<string, unknown>)) {
|
||||||
if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
|
if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
|
||||||
patchUpdates[`recurrenceOverrides/${storeEvent.recurrenceId}/${key}`] = value;
|
overrideObj[key] = value;
|
||||||
}
|
}
|
||||||
|
const patchUpdates: Record<string, unknown> = {
|
||||||
|
[`recurrenceOverrides/${escapedRecurrenceId}`]: overrideObj,
|
||||||
|
};
|
||||||
await client.updateCalendarEvent(
|
await client.updateCalendarEvent(
|
||||||
resolvedId,
|
resolvedId,
|
||||||
patchUpdates as unknown as Partial<CalendarEvent>,
|
patchUpdates as unknown as Partial<CalendarEvent>,
|
||||||
@@ -261,12 +270,17 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
const resolvedId = realEvent.originalId || realEvent.id;
|
const resolvedId = realEvent.originalId || realEvent.id;
|
||||||
if (storeEvent.recurrenceId) {
|
if (storeEvent.recurrenceId) {
|
||||||
// Recurring instance: patch RSVP as recurrence override on master
|
// Recurring instance: patch RSVP as recurrence override on master
|
||||||
const overridePatch: Record<string, unknown> = {
|
// Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1
|
||||||
[`recurrenceOverrides/${storeEvent.recurrenceId}/${patchKey}`]: status,
|
const escapedRecId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||||
|
const overrideObj: Record<string, unknown> = {
|
||||||
|
[patchKey]: status,
|
||||||
};
|
};
|
||||||
if (replyTo) {
|
if (replyTo) {
|
||||||
overridePatch[`recurrenceOverrides/${storeEvent.recurrenceId}/replyTo`] = replyTo;
|
overrideObj['replyTo'] = replyTo;
|
||||||
}
|
}
|
||||||
|
const overridePatch: Record<string, unknown> = {
|
||||||
|
[`recurrenceOverrides/${escapedRecId}`]: overrideObj,
|
||||||
|
};
|
||||||
await client.updateCalendarEvent(
|
await client.updateCalendarEvent(
|
||||||
resolvedId,
|
resolvedId,
|
||||||
overridePatch as unknown as Partial<CalendarEvent>,
|
overridePatch as unknown as Partial<CalendarEvent>,
|
||||||
@@ -392,7 +406,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
imported++;
|
imported++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error instanceof Error ? error.message : '';
|
const msg = error instanceof Error ? error.message : '';
|
||||||
if (msg.includes('already exists') && src.uid) {
|
if ((msg.includes('already exists') || msg.includes('duplicate') || msg.includes('conflict')) && src.uid) {
|
||||||
const { events: storeEvents } = get();
|
const { events: storeEvents } = get();
|
||||||
const alreadyInStore = storeEvents.some((e) => e.uid === src.uid);
|
const alreadyInStore = storeEvents.some((e) => e.uid === src.uid);
|
||||||
if (alreadyInStore) {
|
if (alreadyInStore) {
|
||||||
|
|||||||
Reference in New Issue
Block a user