65c26f7627
- Add SEQUENCE property to ICS generation (starts at 1 for new events) - Increment SEQUENCE on event updates (modal, drag & drop, resize) - Handle SEQUENCE independently for recurring event instances - Add comprehensive tests for SEQUENCE generation and incrementation - Ensure proper event ordering for remote calendar participants 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
614 lines
17 KiB
TypeScript
614 lines
17 KiB
TypeScript
import { userAttendee } from "../User/userDataTypes";
|
|
import { AlarmObject, CalendarEvent, RepetitionObject } from "./EventsTypes";
|
|
import ICAL from "ical.js";
|
|
import { TIMEZONES } from "../../utils/timezone-data";
|
|
import moment from "moment-timezone";
|
|
import {
|
|
convertFormDateTimeToISO,
|
|
detectDateTimeFormat,
|
|
} from "../../components/Event/utils/dateTimeHelpers";
|
|
type RawEntry = [string, Record<string, string>, string, any];
|
|
|
|
function resolveTimezoneId(tzid?: string): string | undefined {
|
|
if (!tzid) return undefined;
|
|
if (TIMEZONES.zones[tzid]) {
|
|
return tzid;
|
|
}
|
|
const alias = TIMEZONES.aliases[tzid];
|
|
if (alias) {
|
|
return alias.aliasTo;
|
|
}
|
|
return tzid;
|
|
}
|
|
|
|
function inferTimezoneFromValue(
|
|
params: Record<string, string> | undefined,
|
|
value: string
|
|
): string | undefined {
|
|
if (!params) {
|
|
if (typeof value === "string" && value.endsWith("Z")) {
|
|
return "Etc/UTC";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const tzParam =
|
|
params.tzid || params.TZID || params.Tzid || params.tZid || params.tzId;
|
|
|
|
if (tzParam) {
|
|
const resolved = resolveTimezoneId(tzParam);
|
|
if (resolved) {
|
|
return resolved;
|
|
}
|
|
}
|
|
|
|
if (typeof value === "string" && value.endsWith("Z")) {
|
|
return "Etc/UTC";
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
export function parseCalendarEvent(
|
|
data: RawEntry[],
|
|
color: Record<string, string>,
|
|
calendarid: string,
|
|
eventURL: string,
|
|
valarm?: RawEntry[]
|
|
): CalendarEvent {
|
|
const event: Partial<CalendarEvent> = { color, attendee: [] };
|
|
let recurrenceId;
|
|
let duration;
|
|
const dateRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
|
|
|
for (const [key, params, , value] of data) {
|
|
switch (key.toLowerCase()) {
|
|
case "uid":
|
|
event.uid = value;
|
|
break;
|
|
case "transp":
|
|
event.transp = value;
|
|
break;
|
|
case "dtstart": {
|
|
event.start = value;
|
|
const detectedTz = inferTimezoneFromValue(params, value);
|
|
if (detectedTz) {
|
|
event.timezone = detectedTz;
|
|
}
|
|
if (dateRegex.test(value)) {
|
|
event.allday = true;
|
|
} else {
|
|
event.allday = false;
|
|
}
|
|
break;
|
|
}
|
|
case "dtend": {
|
|
event.end = value;
|
|
if (!event.timezone) {
|
|
const detectedTz = inferTimezoneFromValue(params, value);
|
|
if (detectedTz) {
|
|
event.timezone = detectedTz;
|
|
}
|
|
}
|
|
if (dateRegex.test(value)) {
|
|
event.allday = true;
|
|
} else {
|
|
event.allday = false;
|
|
}
|
|
break;
|
|
}
|
|
case "class":
|
|
event.class = value;
|
|
break;
|
|
case "x-openpaas-videoconference":
|
|
event.x_openpass_videoconference = value;
|
|
break;
|
|
case "summary":
|
|
event.title = value;
|
|
break;
|
|
case "description":
|
|
event.description = value;
|
|
break;
|
|
case "location":
|
|
event.location = value;
|
|
break;
|
|
case "organizer":
|
|
event.organizer = {
|
|
cn: params?.cn ?? "",
|
|
cal_address: value?.replace(/^mailto:/i, ""),
|
|
};
|
|
break;
|
|
case "attendee":
|
|
(event.attendee as userAttendee[]).push({
|
|
cn: params?.cn ?? "",
|
|
cal_address: value.replace(/^mailto:/i, ""),
|
|
partstat: params?.partstat ?? "",
|
|
rsvp: params?.rsvp ?? "",
|
|
role: params?.role ?? "",
|
|
cutype: params?.cutype ?? "",
|
|
});
|
|
break;
|
|
case "dtstamp":
|
|
event.stamp = value;
|
|
break;
|
|
case "sequence":
|
|
event.sequence = Number(value);
|
|
break;
|
|
case "recurrence-id":
|
|
recurrenceId = value;
|
|
break;
|
|
case "exdate":
|
|
if (!event.exdates) event.exdates = [];
|
|
event.exdates.push(value);
|
|
break;
|
|
case "status":
|
|
event.status = String(value);
|
|
break;
|
|
case "duration":
|
|
duration = String(value);
|
|
break;
|
|
case "rrule":
|
|
event.repetition = { freq: value.freq.toLowerCase() };
|
|
if (value.byday) {
|
|
if (typeof value.byday === "string") {
|
|
event.repetition.byday = [value.byday];
|
|
} else {
|
|
event.repetition.byday = value.byday;
|
|
}
|
|
}
|
|
if (value.until) {
|
|
event.repetition.endDate = value.until;
|
|
}
|
|
if (value.count) {
|
|
event.repetition.occurrences = value.count;
|
|
}
|
|
if (value.interval) {
|
|
event.repetition.interval = value.interval;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (recurrenceId && event.uid) {
|
|
event.uid = `${event.uid}/${recurrenceId}`;
|
|
event.recurrenceId = recurrenceId;
|
|
}
|
|
|
|
if (valarm) {
|
|
event.alarm = {} as AlarmObject;
|
|
for (const [key, , , value] of valarm[1]) {
|
|
switch (key.toLowerCase()) {
|
|
case "action":
|
|
event.alarm.action = value;
|
|
break;
|
|
case "trigger":
|
|
event.alarm.trigger = value;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
event.URL = eventURL;
|
|
if (!event.uid || !event.start || (!event.end && !duration)) {
|
|
console.error(
|
|
`missing crucial event param in calendar ${calendarid} `,
|
|
data
|
|
);
|
|
event.error = `missing crucial event param in calendar ${calendarid} `;
|
|
}
|
|
|
|
const eventTimezone = event.timezone || "Etc/UTC";
|
|
event.timezone = eventTimezone;
|
|
|
|
if (!event.end) {
|
|
const start = event.start ? new Date(event.start) : new Date();
|
|
const timeToAdd = moment.duration(duration).asMilliseconds();
|
|
const artificialEnd = new Date(start.getTime() + timeToAdd);
|
|
event.end = formatDateToICal(artificialEnd, false, eventTimezone);
|
|
}
|
|
|
|
if (!event.allday && event.start && eventTimezone) {
|
|
const startISO = convertDateTimeStringToISO(event.start, eventTimezone);
|
|
if (startISO) {
|
|
event.start = startISO;
|
|
}
|
|
}
|
|
|
|
if (!event.allday && event.end && eventTimezone) {
|
|
const endISO = convertDateTimeStringToISO(event.end, eventTimezone);
|
|
if (endISO) {
|
|
event.end = endISO;
|
|
}
|
|
}
|
|
|
|
return event as CalendarEvent;
|
|
}
|
|
|
|
function convertDateTimeStringToISO(
|
|
datetime: string,
|
|
timezone: string
|
|
): string | undefined {
|
|
if (!datetime || !timezone) return undefined;
|
|
|
|
if (datetime.includes("Z") || datetime.match(/[+-]\d{2}:\d{2}$/)) {
|
|
return undefined;
|
|
}
|
|
|
|
const dateOnlyRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
|
if (dateOnlyRegex.test(datetime)) {
|
|
return undefined;
|
|
}
|
|
|
|
const format = detectDateTimeFormat(datetime);
|
|
const momentDate = moment.tz(datetime, format, timezone);
|
|
if (!momentDate.isValid()) {
|
|
console.warn(
|
|
`[convertDateTimeStringToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${timezone}"`
|
|
);
|
|
return undefined;
|
|
}
|
|
return momentDate.toISOString();
|
|
}
|
|
|
|
export function calendarEventToJCal(
|
|
event: CalendarEvent,
|
|
calOwnerEmail?: string
|
|
): any[] {
|
|
const tzid = event.timezone; // Fallback to UTC if no timezone provided
|
|
|
|
const vevent: any[] = makeVevent(event, tzid, calOwnerEmail);
|
|
|
|
const timezoneData = TIMEZONES.zones[event.timezone];
|
|
const vtimezone = makeTimezone(timezoneData, event);
|
|
|
|
return ["vcalendar", [], [vevent, vtimezone.component.jCal]];
|
|
}
|
|
|
|
export function makeTimezone(
|
|
timezoneData: { ics: string; latitude: string; longitude: string },
|
|
event: CalendarEvent
|
|
) {
|
|
if (!timezoneData) {
|
|
return new ICAL.Timezone({
|
|
component: TIMEZONES.zones["Etc/UTC"].ics,
|
|
tzid: "Etc/UTC",
|
|
});
|
|
}
|
|
return new ICAL.Timezone({
|
|
component: timezoneData.ics,
|
|
tzid: event.timezone,
|
|
});
|
|
}
|
|
|
|
export function makeVevent(
|
|
event: CalendarEvent,
|
|
tzid: string,
|
|
calOwnerEmail: string | undefined,
|
|
isMasterEvent?: boolean
|
|
) {
|
|
const vevent: any[] = [
|
|
"vevent",
|
|
[
|
|
["uid", {}, "text", event.uid.split("/")[0]],
|
|
["transp", {}, "text", event.transp ?? "OPAQUE"],
|
|
[
|
|
"dtstart",
|
|
{ tzid },
|
|
event.allday ? "date" : "date-time",
|
|
formatDateToICal(new Date(event.start), event.allday ?? false, tzid),
|
|
],
|
|
["class", {}, "text", event.class ?? "PUBLIC"],
|
|
["sequence", {}, "integer", event.sequence ?? 1],
|
|
[
|
|
"x-openpaas-videoconference",
|
|
{},
|
|
"unknown",
|
|
event.x_openpass_videoconference ?? null,
|
|
],
|
|
["summary", {}, "text", event.title ?? ""],
|
|
],
|
|
];
|
|
if (event.alarm?.trigger) {
|
|
const valarm = [
|
|
["trigger", {}, "duration", event.alarm.trigger],
|
|
["action", {}, "text", event.alarm.action],
|
|
["attendee", {}, "cal-address", `mailto:${calOwnerEmail}`],
|
|
["summary", {}, "text", event.title],
|
|
[
|
|
"description",
|
|
{},
|
|
"text",
|
|
"This is an automatic alarm sent by OpenPaas",
|
|
],
|
|
];
|
|
vevent.push([["valarm", valarm]]);
|
|
}
|
|
vevent.push([]);
|
|
|
|
if (event.end) {
|
|
const startDate = new Date(event.start);
|
|
const endDate = new Date(event.end);
|
|
let finalEndDate = endDate;
|
|
|
|
if (event.allday && endDate.getTime() === startDate.getTime()) {
|
|
finalEndDate = new Date(endDate);
|
|
finalEndDate.setDate(startDate.getDate() + 1);
|
|
}
|
|
|
|
vevent[1].push([
|
|
"dtend",
|
|
{ tzid },
|
|
event.allday ? "date" : "date-time",
|
|
formatDateToICal(finalEndDate, event.allday ?? false, tzid),
|
|
]);
|
|
}
|
|
if (event.organizer) {
|
|
vevent[1].push([
|
|
"organizer",
|
|
{ cn: event.organizer.cn },
|
|
"cal-address",
|
|
`mailto:${event.organizer.cal_address}`,
|
|
]);
|
|
}
|
|
if (event.location) {
|
|
vevent[1].push(["location", {}, "text", event.location]);
|
|
}
|
|
if (event.recurrenceId && !isMasterEvent) {
|
|
vevent[1].push(["recurrence-id", {}, "date-time", event.recurrenceId]);
|
|
}
|
|
if (event.description) {
|
|
vevent[1].push(["description", {}, "text", event.description]);
|
|
}
|
|
if (event.repetition?.freq) {
|
|
const repetitionRule: Record<string, any> = { freq: event.repetition.freq };
|
|
if (event.repetition.interval) {
|
|
repetitionRule["interval"] = event.repetition.interval;
|
|
}
|
|
if (event.repetition.occurrences) {
|
|
repetitionRule["count"] = event.repetition.occurrences;
|
|
}
|
|
if (event.repetition.endDate) {
|
|
repetitionRule["until"] = event.repetition.endDate;
|
|
}
|
|
if (
|
|
event.repetition.byday !== null &&
|
|
event.repetition.byday !== undefined
|
|
) {
|
|
repetitionRule["byday"] = event.repetition.byday;
|
|
}
|
|
vevent[1].push(["rrule", {}, "recur", repetitionRule]);
|
|
}
|
|
|
|
event.attendee.forEach((att) => {
|
|
const attendee: Record<string, string> = {
|
|
partstat: att.partstat,
|
|
rsvp: att.rsvp,
|
|
role: att.role,
|
|
cutype: att.cutype,
|
|
};
|
|
if (att.cn) {
|
|
attendee.cn = att.cn;
|
|
}
|
|
vevent[1].push([
|
|
"attendee",
|
|
attendee,
|
|
"cal-address",
|
|
`mailto:${att.cal_address}`,
|
|
]);
|
|
});
|
|
|
|
if (event.exdates && event.exdates.length > 0) {
|
|
event.exdates.forEach((ex) => {
|
|
vevent[1].push([
|
|
"exdate",
|
|
{ tzid },
|
|
"date-time",
|
|
formatDateToICal(new Date(ex), false, tzid),
|
|
]);
|
|
});
|
|
}
|
|
|
|
return vevent;
|
|
}
|
|
|
|
function formatDateToICal(date: Date, allday: Boolean, timezone?: string) {
|
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
|
|
|
if (allday) {
|
|
const year = date.getUTCFullYear();
|
|
const month = pad(date.getUTCMonth() + 1);
|
|
const day = pad(date.getUTCDate());
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
if (timezone) {
|
|
const momentDate = moment.utc(date).tz(timezone);
|
|
if (momentDate.isValid()) {
|
|
return momentDate.format("YYYY-MM-DDTHH:mm:ss");
|
|
}
|
|
}
|
|
|
|
const year = date.getUTCFullYear();
|
|
const month = pad(date.getUTCMonth() + 1);
|
|
const day = pad(date.getUTCDate());
|
|
const hours = pad(date.getUTCHours());
|
|
const minutes = pad(date.getUTCMinutes());
|
|
const seconds = pad(date.getUTCSeconds());
|
|
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
|
}
|
|
|
|
/**
|
|
* Combine master event's date with form input's time for recurring event updates
|
|
* Preserves original start date while applying new time from user input
|
|
*/
|
|
export function combineMasterDateWithFormTime(
|
|
masterEvent: CalendarEvent,
|
|
formStart: string,
|
|
formEnd: string,
|
|
timezone: string,
|
|
isAllDay: boolean,
|
|
formatDateTimeInTimezone: (iso: string, tz: string) => string
|
|
): { startDate: string; endDate: string } {
|
|
if (isAllDay) {
|
|
// Extract date string from master event (which is ISO UTC string)
|
|
const startDateStr = new Date(masterEvent.start)
|
|
.toISOString()
|
|
.split("T")[0];
|
|
const endDateStr = masterEvent.end
|
|
? new Date(masterEvent.end).toISOString().split("T")[0]
|
|
: startDateStr;
|
|
|
|
// Parse date string and create Date at UTC midnight to avoid timezone offset issues
|
|
const [startYear, startMonth, startDay] = startDateStr
|
|
.split("-")
|
|
.map(Number);
|
|
const [endYear, endMonth, endDay] = endDateStr.split("-").map(Number);
|
|
const startDateObj = new Date(
|
|
Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0)
|
|
);
|
|
const endDateObj = new Date(
|
|
Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0)
|
|
);
|
|
|
|
return {
|
|
startDate: startDateObj.toISOString(),
|
|
endDate: endDateObj.toISOString(),
|
|
};
|
|
}
|
|
|
|
// For timed events: combine master's date with form's time
|
|
const masterFormattedStart = formatDateTimeInTimezone(
|
|
masterEvent.start,
|
|
timezone
|
|
);
|
|
const masterFormattedEnd = masterEvent.end
|
|
? formatDateTimeInTimezone(masterEvent.end, timezone)
|
|
: masterFormattedStart;
|
|
|
|
// Extract date portion from master (YYYY-MM-DD)
|
|
const masterDatePart = masterFormattedStart.split("T")[0];
|
|
const masterEndDatePart = masterFormattedEnd.split("T")[0];
|
|
|
|
// Extract time portion from form input (HH:MM or HH:MM:SS)
|
|
const formTimePart = formStart.includes("T")
|
|
? formStart.split("T")[1]
|
|
: formStart.substring(11);
|
|
const formEndTimePart = formEnd.includes("T")
|
|
? formEnd.split("T")[1]
|
|
: formEnd.substring(11);
|
|
|
|
// Combine master's date + form's time
|
|
const combinedStartStr = `${masterDatePart}T${formTimePart}`;
|
|
const combinedEndStr = `${masterEndDatePart}T${formEndTimePart}`;
|
|
|
|
// Parse and convert to ISO
|
|
const startDate = convertFormDateTimeToISO(combinedStartStr, timezone);
|
|
const endDate = convertFormDateTimeToISO(combinedEndStr, timezone);
|
|
|
|
return { startDate, endDate };
|
|
}
|
|
|
|
/**
|
|
* Normalize repetition object for accurate comparison
|
|
*/
|
|
export function normalizeRepetition(repetition: RepetitionObject | undefined): {
|
|
freq: string;
|
|
interval: number;
|
|
byday: string[] | null;
|
|
occurrences: number | null;
|
|
endDate: string | null;
|
|
} | null {
|
|
if (!repetition || !repetition.freq) return null;
|
|
|
|
return {
|
|
freq: repetition.freq,
|
|
interval: repetition.interval || 1,
|
|
byday:
|
|
!repetition.byday || repetition.byday.length === 0
|
|
? null
|
|
: [...repetition.byday].sort(),
|
|
occurrences: repetition.occurrences || null,
|
|
endDate: repetition.endDate || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalize timezone by resolving aliases
|
|
*/
|
|
export function normalizeTimezone(
|
|
timezone: string | undefined | null,
|
|
resolveTimezone: (tz: string) => string
|
|
): string | null {
|
|
if (!timezone) return null;
|
|
return resolveTimezone(timezone);
|
|
}
|
|
|
|
/**
|
|
* Detect what changed in recurring event update
|
|
*/
|
|
export function detectRecurringEventChanges(
|
|
oldEvent: CalendarEvent,
|
|
newData: {
|
|
repetition: RepetitionObject;
|
|
timezone: string;
|
|
allday: boolean;
|
|
start: string;
|
|
end: string;
|
|
},
|
|
masterEventData: CalendarEvent | null,
|
|
resolveTimezone: (tz: string) => string,
|
|
formatDateTimeInTimezone: (iso: string, tz: string) => string
|
|
): {
|
|
timeChanged: boolean;
|
|
timezoneChanged: boolean;
|
|
repetitionRulesChanged: boolean;
|
|
} {
|
|
const oldRepetition = normalizeRepetition(oldEvent.repetition);
|
|
const newRepetition = normalizeRepetition(newData.repetition);
|
|
|
|
const oldTimezone = normalizeTimezone(oldEvent.timezone, resolveTimezone);
|
|
const newTimezone = normalizeTimezone(newData.timezone, resolveTimezone);
|
|
const timezoneChanged = oldTimezone !== newTimezone;
|
|
|
|
// Check if TIME changed (compare time portion only, not date)
|
|
const extractTimeFromForm = (localDateTimeStr: string) => {
|
|
if (!localDateTimeStr) return null;
|
|
const timePart = localDateTimeStr.includes("T")
|
|
? localDateTimeStr.split("T")[1]
|
|
: localDateTimeStr.substring(11);
|
|
return timePart?.substring(0, 5); // HH:MM
|
|
};
|
|
|
|
const extractTimeFromISO = (isoString: string | undefined, tz: string) => {
|
|
if (!isoString) return null;
|
|
const formatted = formatDateTimeInTimezone(isoString, tz);
|
|
const timePart = formatted.includes("T")
|
|
? formatted.split("T")[1]
|
|
: formatted.substring(11);
|
|
return timePart?.substring(0, 5); // HH:MM
|
|
};
|
|
|
|
const masterOldStart = masterEventData?.start || oldEvent.start;
|
|
const masterOldEnd = masterEventData?.end || oldEvent.end;
|
|
|
|
const formStartTime = extractTimeFromForm(newData.start);
|
|
const formEndTime = extractTimeFromForm(newData.end);
|
|
|
|
const oldStartTime = extractTimeFromISO(masterOldStart, newData.timezone);
|
|
const oldEndTime = extractTimeFromISO(masterOldEnd, newData.timezone);
|
|
|
|
const timeChanged =
|
|
formStartTime !== oldStartTime || formEndTime !== oldEndTime;
|
|
|
|
const repetitionRulesChanged =
|
|
JSON.stringify(oldRepetition) !== JSON.stringify(newRepetition) ||
|
|
timezoneChanged ||
|
|
oldEvent.allday !== newData.allday ||
|
|
timeChanged;
|
|
|
|
return {
|
|
timeChanged,
|
|
timezoneChanged,
|
|
repetitionRulesChanged,
|
|
};
|
|
}
|