[refactor] preparation for PR adressing #638 (#644)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2026-03-17 09:32:27 +01:00
committed by GitHub
parent 8da9505e01
commit a9e7b1ac82
24 changed files with 646 additions and 595 deletions
@@ -15,8 +15,6 @@ import {
getEventAsync,
getTempCalendarsListAsync,
patchACLCalendarAsync,
patchCalendarAsync,
removeCalendarAsync,
} from "@/features/Calendars/services";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import * as userAPI from "@/features/User/userAPI";
@@ -26,7 +24,7 @@ import { configureStore } from "@reduxjs/toolkit";
jest.mock("@/features/Calendars/CalendarApi");
jest.mock("@/features/User/userAPI");
jest.mock("@/features/Events/EventApi");
jest.mock("@/features/Events/eventUtils");
jest.mock("@/features/Events/utils");
jest.mock("@/utils/apiUtils");
describe("CalendarSlice", () => {
+1 -1
View File
@@ -7,7 +7,7 @@ import {
searchEvent,
} from "@/features/Events/EventApi";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import { calendarEventToJCal } from "@/features/Events/eventUtils";
import { calendarEventToJCal } from "@/features/Events/utils";
import { clientConfig } from "@/features/User/oidcAuth";
import { api } from "@/utils/apiUtils";
+1 -1
View File
@@ -8,7 +8,7 @@ import {
normalizeRepetition,
normalizeTimezone,
parseCalendarEvent,
} from "@/features/Events/eventUtils";
} from "@/features/Events/utils";
describe("parseCalendarEvent", () => {
const baseColor = { light: "#00FF00" };
+1 -1
View File
@@ -1,5 +1,5 @@
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { buildDelegatedEventURL } from "@/features/Events/eventUtils";
import { buildDelegatedEventURL } from "@/features/Events/utils";
const makeCalendar = (link: string): Calendar =>
({
@@ -1,5 +1,5 @@
import { CalendarEvent } from "@/features/Events/EventsTypes";
import { buildDelegatedEventURL } from "@/features/Events/eventUtils";
import { buildDelegatedEventURL } from "@/features/Events/utils";
import { toRejectedError } from "@/utils/errorUtils";
import { createAsyncThunk } from "@reduxjs/toolkit";
import pMap from "p-map";
@@ -1,6 +1,6 @@
import { defaultColors } from "@/utils/defaultColors";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import { parseCalendarEvent } from "@/features/Events/eventUtils";
import { parseCalendarEvent } from "@/features/Events/utils";
import { defaultColors } from "@/utils/defaultColors";
import { CalDavItem } from "../api/types";
import { Calendar } from "../CalendarTypes";
import { VCalComponent } from "../types/CalendarData";
+1 -1
View File
@@ -16,7 +16,7 @@ import {
makeTimezone,
makeVevent,
parseCalendarEvent,
} from "./eventUtils";
} from "./utils";
export async function reportEvent(
event: CalendarEvent,
+1 -1
View File
@@ -41,8 +41,8 @@ import { Calendar } from "../Calendars/CalendarTypes";
import { putEventAsync } from "../Calendars/services";
import { userAttendee } from "../User/models/attendee";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { buildDelegatedEventURL } from "./eventUtils";
import { useEventOrganizer } from "./useEventOrganizer";
import { buildDelegatedEventURL } from "./utils/buildDelegatedEventURL";
function EventPopover({
open,
+1 -1
View File
@@ -42,8 +42,8 @@ import { Calendar } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/models/attendee";
import { deleteEvent, getEvent, putEvent } from "./EventApi";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { detectRecurringEventChanges } from "./eventUtils";
import { moveEventBetweenCalendars } from "./updateEventHelpers/moveEventBetweenCalendars";
import { detectRecurringEventChanges } from "./utils/detectRecurringEventChanges";
function EventUpdateModal({
eventId,
+1 -1
View File
@@ -6,7 +6,7 @@ import {
} from "../../Calendars/types/CalendarData";
import { getAllRecurrentEvent } from "../EventApi";
import { CalendarEvent } from "../EventsTypes";
import { makeTimezone, makeVevent } from "../eventUtils";
import { makeTimezone, makeVevent } from "../utils";
const METADATA_FIELDS = [
"summary",
-581
View File
@@ -1,581 +0,0 @@
import { convertFormDateTimeToISO } from "@/components/Event/utils/dateTimeHelpers";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { convertEventDateTimeToISO, resolveTimezoneId } from "@/utils/timezone";
import { TIMEZONES } from "@/utils/timezone-data";
import ICAL from "ical.js";
import moment from "moment-timezone";
import { Calendar } from "../Calendars/CalendarTypes";
import {
RepetitionRule,
VCalComponent,
VObjectProperty,
} from "../Calendars/types/CalendarData";
import { userAttendee } from "../User/models/attendee";
import { createAttendee } from "../User/models/attendee.mapper";
import { AlarmObject, CalendarEvent, RepetitionObject } from "./EventsTypes";
function inferTimezoneFromValue(
params: Record<string, string> | undefined
): string | undefined {
if (!params) {
return undefined;
}
const tzParam =
params.tzid || params.TZID || params.Tzid || params.tZid || params.tzId;
if (tzParam) {
const resolved = resolveTimezoneId(tzParam);
if (resolved) {
return resolved;
}
}
return undefined;
}
export function parseCalendarEvent(
data: VObjectProperty[],
color: Record<string, string>,
calendar: Calendar,
eventURL: string,
valarm?: VCalComponent
): 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 = String(value);
break;
case "transp":
event.transp = String(value);
break;
case "dtstart": {
event.start = String(value);
const detectedTz = inferTimezoneFromValue(
params as Record<string, string>
);
if (detectedTz) {
event.timezone = detectedTz;
}
if (dateRegex.test(String(value))) {
event.allday = true;
} else {
event.allday = false;
}
break;
}
case "dtend": {
event.end = String(value);
if (!event.timezone) {
const detectedTz = inferTimezoneFromValue(
params as Record<string, string>
);
if (detectedTz) {
event.timezone = detectedTz;
}
}
if (dateRegex.test(String(value))) {
event.allday = true;
} else {
event.allday = false;
}
break;
}
case "class":
event.class = String(value);
break;
case "x-openpaas-videoconference":
event.x_openpass_videoconference = String(value);
break;
case "summary":
event.title = String(value);
break;
case "description":
event.description = String(value);
break;
case "location":
event.location = String(value);
break;
case "organizer": {
const paramsObj = params as Record<string, string>;
event.organizer = {
cn: paramsObj?.cn ?? "",
cal_address: String(value).replace(/^mailto:/i, ""),
};
break;
}
case "attendee": {
const paramsObj = params as Record<string, string>;
if (
!event.attendee?.find(
(attendee) =>
attendee.cal_address === String(value).replace(/^mailto:/i, "")
)
) {
(event.attendee as userAttendee[]).push(
createAttendee({
cn: paramsObj?.cn,
cal_address: String(value).replace(/^mailto:/i, ""),
partstat: paramsObj?.partstat as userAttendee["partstat"],
rsvp: paramsObj?.rsvp as userAttendee["rsvp"],
role: paramsObj?.role as userAttendee["role"],
cutype: paramsObj?.cutype as userAttendee["cutype"],
})
);
}
break;
}
case "dtstamp":
event.stamp = String(value);
break;
case "sequence":
event.sequence = Number(value);
break;
case "recurrence-id":
recurrenceId = String(value);
break;
case "exdate":
if (!event.exdates) event.exdates = [];
event.exdates.push(String(value));
break;
case "status":
event.status = String(value);
break;
case "duration":
duration = String(value);
break;
case "rrule": {
const ruleValue = value as RepetitionRule;
event.repetition = { freq: ruleValue.freq.toLowerCase() };
if (ruleValue.byday) {
if (typeof ruleValue.byday === "string") {
event.repetition.byday = [ruleValue.byday];
} else {
event.repetition.byday = ruleValue.byday;
}
}
if (ruleValue.until) {
event.repetition.endDate = ruleValue.until;
}
if (ruleValue.count) {
event.repetition.occurrences = ruleValue.count;
}
if (ruleValue.interval) {
event.repetition.interval = ruleValue.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 = String(value);
break;
case "trigger":
event.alarm.trigger = String(value);
break;
}
}
}
event.calId = calendar.id;
event.URL = calendar.delegated
? buildDelegatedEventURL(calendar, eventURL)
: eventURL;
if (!event.uid || !event.start) {
console.error(
`missing crucial event param in calendar ${calendar.id} `,
data
);
event.error = `missing crucial event param in calendar ${calendar.id} `;
}
const eventTimezone = event.timezone;
if (!event.end) {
const start = event.start ? new Date(event.start) : new Date();
const timeToAdd = duration
? moment.duration(duration).asMilliseconds()
: moment.duration(30, "minutes").asMilliseconds();
const artificialEnd = new Date(start.getTime() + timeToAdd);
event.end = formatDateToICal(artificialEnd, false, eventTimezone);
}
if (!event.allday && event.start && eventTimezone) {
const startISO = convertEventDateTimeToISO(event.start, eventTimezone);
if (startISO) {
event.start = startISO;
}
}
if (!event.allday && event.end && eventTimezone) {
const endISO = convertEventDateTimeToISO(event.end, eventTimezone);
if (endISO) {
event.end = endISO;
}
}
return event as CalendarEvent;
}
export function calendarEventToJCal(
event: CalendarEvent,
calOwnerEmail?: string
) {
const tzid = event.timezone; // Fallback to UTC if no timezone provided
const vevent = 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: [string, unknown[]] = [
"vevent",
[
["uid", {}, "text", extractEventBaseUuid(event.uid)],
["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]]);
}
if (event.end) {
vevent[1].push([
"dtend",
{ tzid },
event.allday ? "date" : "date-time",
formatDateToICal(new Date(event.end), 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: RepetitionRule = { 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}Z`;
}
/**
* 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
): {
timeChanged: boolean;
timezoneChanged: boolean;
repetitionRulesChanged: boolean;
} {
const oldTimezone = resolveTimezone(oldEvent.timezone || "UTC");
const newTimezone = resolveTimezone(newData.timezone || "UTC");
const timezoneChanged = oldTimezone !== newTimezone;
// Use master event as the source of truth for the "old" times,
// falling back to the clicked instance if master isn't available.
const oldStart = masterEventData?.start || oldEvent.start;
const oldEnd = masterEventData?.end || oldEvent.end;
// Parse old times (ISO strings from the server) into the event's timezone
// and extract HH:mm for comparison.
const oldStartTime = moment.tz(oldStart, oldTimezone).format("HH:mm");
const oldEndTime = moment.tz(oldEnd, oldTimezone).format("HH:mm");
// Parse new times from the form. These may be either:
// - local datetime strings like "2025-01-15T10:00" (from the form)
// - ISO strings like "2025-01-15T10:00:00.000Z" (if pre-converted)
// moment.tz with a format avoids ambiguous parsing in both cases.
const newStartTime = moment.tz(newData.start, newTimezone).format("HH:mm");
const newEndTime = moment.tz(newData.end, newTimezone).format("HH:mm");
const timeChanged =
oldStartTime !== newStartTime || oldEndTime !== newEndTime;
const repetitionRulesChanged =
JSON.stringify(normalizeRepetition(oldEvent.repetition)) !==
JSON.stringify(normalizeRepetition(newData.repetition)) ||
timezoneChanged ||
oldEvent.allday !== newData.allday ||
timeChanged;
return {
timeChanged,
timezoneChanged,
repetitionRulesChanged,
};
}
export function buildDelegatedEventURL(
calendar: Calendar,
eventURL: string
): string {
const calendarBasePath = calendar.link.replace(/\.json$/, "");
const eventFilename = eventURL.split("/").pop();
if (!eventFilename) {
throw new Error(`Cannot extract filename from event URL: ${eventURL}`);
}
return `${calendarBasePath}/${eventFilename}`;
}
@@ -11,7 +11,7 @@ import { assertThunkSuccess } from "@/utils/assertThunkSuccess";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { makeDisplayName } from "@/utils/makeDisplayName";
import { CalendarEvent } from "../EventsTypes";
import { buildDelegatedEventURL } from "../eventUtils";
import { buildDelegatedEventURL } from "../utils/buildDelegatedEventURL";
export interface MoveEventBetweenCalendarsParams {
dispatch: AppDispatch;
@@ -0,0 +1,16 @@
import { Calendar } from "@/features/Calendars/CalendarTypes";
export function buildDelegatedEventURL(
calendar: Calendar,
eventURL: string
): string {
if (!calendar.link || !calendar.link.endsWith(".json")) {
throw new Error(`Invalid calendar link format: ${calendar.link}`);
}
const calendarBasePath = calendar.link.replace(/\.json$/, "");
const eventFilename = eventURL.split("/").pop();
if (!eventFilename) {
throw new Error(`Cannot extract filename from event URL: ${eventURL}`);
}
return `${calendarBasePath}/${eventFilename}`;
}
@@ -0,0 +1,18 @@
import { TIMEZONES } from "@/utils/timezone-data";
import { CalendarEvent } from "../EventsTypes";
import { makeVevent } from "./makeVevent";
import { makeTimezone } from "./makeTimezone";
export function calendarEventToJCal(
event: CalendarEvent,
calOwnerEmail?: string
) {
const tzid = event.timezone;
const vevent = makeVevent(event, tzid, calOwnerEmail);
const timezoneData = TIMEZONES.zones[event.timezone];
const vtimezone = makeTimezone(timezoneData, event);
return ["vcalendar", [], [vevent, vtimezone.component.jCal]];
}
@@ -0,0 +1,74 @@
import { convertFormDateTimeToISO } from "@/components/Event/utils/dateTimeHelpers";
import { CalendarEvent } from "../EventsTypes";
/**
* 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 };
}
@@ -0,0 +1,59 @@
import moment from "moment-timezone";
import { CalendarEvent, RepetitionObject } from "../EventsTypes";
import { normalizeRepetition } from "./normalizeRepetition";
/**
* 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
): {
timeChanged: boolean;
timezoneChanged: boolean;
repetitionRulesChanged: boolean;
} {
const oldTimezone = resolveTimezone(oldEvent.timezone || "UTC");
const newTimezone = resolveTimezone(newData.timezone || "UTC");
const timezoneChanged = oldTimezone !== newTimezone;
// Use master event as the source of truth for the "old" times,
// falling back to the clicked instance if master isn't available.
const oldStart = masterEventData?.start || oldEvent.start;
const oldEnd = masterEventData?.end || oldEvent.end;
// Parse old times (ISO strings from the server) into the event's timezone
// and extract HH:mm for comparison.
const oldStartTime = moment.tz(oldStart, oldTimezone).format("HH:mm");
const oldEndTime = moment.tz(oldEnd, oldTimezone).format("HH:mm");
// Parse new times from the form. These may be either:
// - local datetime strings like "2025-01-15T10:00" (from the form)
// - ISO strings like "2025-01-15T10:00:00.000Z" (if pre-converted)
// moment.tz with a format avoids ambiguous parsing in both cases.
const newStartTime = moment.tz(newData.start, newTimezone).format("HH:mm");
const newEndTime = moment.tz(newData.end, newTimezone).format("HH:mm");
const timeChanged =
oldStartTime !== newStartTime || oldEndTime !== newEndTime;
const repetitionRulesChanged =
JSON.stringify(normalizeRepetition(oldEvent.repetition)) !==
JSON.stringify(normalizeRepetition(newData.repetition)) ||
timezoneChanged ||
oldEvent.allday !== newData.allday ||
timeChanged;
return {
timeChanged,
timezoneChanged,
repetitionRulesChanged,
};
}
@@ -0,0 +1,37 @@
import moment from "moment-timezone";
export 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) {
if (!moment.tz.zone(timezone)) {
console.warn(
`[formatDateToICal] Unrecognized timezone: "${timezone}", falling back to UTC`
);
} else {
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}Z`;
}
+11
View File
@@ -0,0 +1,11 @@
export { buildDelegatedEventURL } from "./buildDelegatedEventURL";
export { calendarEventToJCal } from "./calendarEventToJCal";
export { combineMasterDateWithFormTime } from "./combineMasterDateWithFormTime";
export { detectRecurringEventChanges } from "./detectRecurringEventChanges";
export { formatDateToICal } from "./formatDateToICal";
export { inferTimezoneFromValue } from "./inferTimezoneFromValue";
export { makeTimezone } from "./makeTimezone";
export { makeVevent } from "./makeVevent";
export { normalizeRepetition } from "./normalizeRepetition";
export { normalizeTimezone } from "./normalizeTimezone";
export { parseCalendarEvent } from "./parseCalendarEvent";
@@ -0,0 +1,20 @@
import { resolveTimezoneId } from "@/utils/timezone";
export function inferTimezoneFromValue(
params: Record<string, string> | undefined
): string | undefined {
if (!params) {
return undefined;
}
const tzKey = Object.keys(params).find((k) => k.toLowerCase() === "tzid");
const tzParam = tzKey ? params[tzKey] : undefined;
if (tzParam) {
const resolved = resolveTimezoneId(tzParam);
if (resolved) {
return resolved;
}
}
return undefined;
}
+21
View File
@@ -0,0 +1,21 @@
import { TIMEZONES } from "@/utils/timezone-data";
import ICAL from "ical.js";
import { CalendarEvent } from "../EventsTypes";
export function makeTimezone(
timezoneData:
| { ics: string; latitude: string; longitude: string }
| undefined,
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,
});
}
+125
View File
@@ -0,0 +1,125 @@
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { RepetitionRule } from "../../Calendars/types/CalendarData";
import { CalendarEvent } from "../EventsTypes";
import { formatDateToICal } from "./formatDateToICal";
export function makeVevent(
event: CalendarEvent,
tzid: string,
calOwnerEmail: string | undefined,
isMasterEvent?: boolean
) {
const vevent: [string, unknown[]] = [
"vevent",
[
["uid", {}, "text", extractEventBaseUuid(event.uid)],
["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]]);
}
if (event.end) {
vevent[1].push([
"dtend",
{ tzid },
event.allday ? "date" : "date-time",
formatDateToICal(new Date(event.end), 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: RepetitionRule = { 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;
}
@@ -0,0 +1,26 @@
import { RepetitionObject } from "../EventsTypes";
/**
* 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,
};
}
@@ -0,0 +1,11 @@
/**
* 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);
}
@@ -0,0 +1,216 @@
import { convertEventDateTimeToISO } from "@/utils/timezone";
import moment from "moment-timezone";
import { Calendar } from "../../Calendars/CalendarTypes";
import {
RepetitionRule,
VCalComponent,
VObjectProperty,
} from "../../Calendars/types/CalendarData";
import { userAttendee } from "../../User/models/attendee";
import { createAttendee } from "../../User/models/attendee.mapper";
import { AlarmObject, CalendarEvent } from "../EventsTypes";
import { inferTimezoneFromValue } from "./inferTimezoneFromValue";
import { buildDelegatedEventURL } from "./buildDelegatedEventURL";
import { formatDateToICal } from "./formatDateToICal";
export function parseCalendarEvent(
data: VObjectProperty[],
color: Record<string, string>,
calendar: Calendar,
eventURL: string,
valarm?: VCalComponent
): 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 = String(value);
break;
case "transp":
event.transp = String(value);
break;
case "dtstart": {
event.start = String(value);
const detectedTz = inferTimezoneFromValue(
params as Record<string, string>
);
if (detectedTz) {
event.timezone = detectedTz;
}
if (dateRegex.test(String(value))) {
event.allday = true;
} else {
event.allday = false;
}
break;
}
case "dtend": {
event.end = String(value);
if (!event.timezone) {
const detectedTz = inferTimezoneFromValue(
params as Record<string, string>
);
if (detectedTz) {
event.timezone = detectedTz;
}
}
if (dateRegex.test(String(value))) {
event.allday = true;
} else {
event.allday = false;
}
break;
}
case "class":
if (
["PRIVATE", "PUBLIC", "CONFIDENTIAL"].includes(
String(value).toUpperCase()
)
) {
event.class = String(value).toUpperCase() as CalendarEvent["class"];
}
break;
case "x-openpaas-videoconference":
event.x_openpass_videoconference = String(value);
break;
case "summary":
event.title = String(value);
break;
case "description":
event.description = String(value);
break;
case "location":
event.location = String(value);
break;
case "organizer": {
const paramsObj = params as Record<string, string>;
event.organizer = {
cn: paramsObj?.cn ?? "",
cal_address: String(value).replace(/^mailto:/i, ""),
};
break;
}
case "attendee": {
const paramsObj = params as Record<string, string>;
if (
!event.attendee?.find(
(attendee) =>
attendee.cal_address === String(value).replace(/^mailto:/i, "")
)
) {
(event.attendee as userAttendee[]).push(
createAttendee({
cn: paramsObj?.cn,
cal_address: String(value).replace(/^mailto:/i, ""),
partstat: paramsObj?.partstat as userAttendee["partstat"],
rsvp: paramsObj?.rsvp as userAttendee["rsvp"],
role: paramsObj?.role as userAttendee["role"],
cutype: paramsObj?.cutype as userAttendee["cutype"],
})
);
}
break;
}
case "dtstamp":
event.stamp = String(value);
break;
case "sequence":
event.sequence = Number(value);
break;
case "recurrence-id":
recurrenceId = String(value);
break;
case "exdate":
if (!event.exdates) event.exdates = [];
event.exdates.push(String(value));
break;
case "status":
event.status = String(value);
break;
case "duration":
duration = String(value);
break;
case "rrule": {
const ruleValue = value as RepetitionRule;
event.repetition = { freq: ruleValue.freq.toLowerCase() };
if (ruleValue.byday) {
if (typeof ruleValue.byday === "string") {
event.repetition.byday = [ruleValue.byday];
} else {
event.repetition.byday = ruleValue.byday;
}
}
if (ruleValue.until) {
event.repetition.endDate = ruleValue.until;
}
if (ruleValue.count) {
event.repetition.occurrences = ruleValue.count;
}
if (ruleValue.interval) {
event.repetition.interval = ruleValue.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 = String(value);
break;
case "trigger":
event.alarm.trigger = String(value);
break;
}
}
}
event.calId = calendar.id;
event.URL = calendar.delegated
? buildDelegatedEventURL(calendar, eventURL)
: eventURL;
if (!event.uid || !event.start) {
console.error(
`missing crucial event param in calendar ${calendar.id} `,
data
);
event.error = `missing crucial event param in calendar ${calendar.id} `;
}
const eventTimezone = event.timezone;
if (!event.end) {
const start = event.start ? new Date(event.start) : new Date();
const timeToAdd = duration
? moment.duration(duration).asMilliseconds()
: moment.duration(30, "minutes").asMilliseconds();
const artificialEnd = new Date(start.getTime() + timeToAdd);
event.end = formatDateToICal(artificialEnd, false, eventTimezone);
}
if (!event.allday && event.start && eventTimezone) {
const startISO = convertEventDateTimeToISO(event.start, eventTimezone);
if (startISO) {
event.start = startISO;
}
}
if (!event.allday && event.end && eventTimezone) {
const endISO = convertEventDateTimeToISO(event.end, eventTimezone);
if (endISO) {
event.end = endISO;
}
}
return event as CalendarEvent;
}