Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
@@ -15,8 +15,6 @@ import {
|
|||||||
getEventAsync,
|
getEventAsync,
|
||||||
getTempCalendarsListAsync,
|
getTempCalendarsListAsync,
|
||||||
patchACLCalendarAsync,
|
patchACLCalendarAsync,
|
||||||
patchCalendarAsync,
|
|
||||||
removeCalendarAsync,
|
|
||||||
} from "@/features/Calendars/services";
|
} from "@/features/Calendars/services";
|
||||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||||
import * as userAPI from "@/features/User/userAPI";
|
import * as userAPI from "@/features/User/userAPI";
|
||||||
@@ -26,7 +24,7 @@ import { configureStore } from "@reduxjs/toolkit";
|
|||||||
jest.mock("@/features/Calendars/CalendarApi");
|
jest.mock("@/features/Calendars/CalendarApi");
|
||||||
jest.mock("@/features/User/userAPI");
|
jest.mock("@/features/User/userAPI");
|
||||||
jest.mock("@/features/Events/EventApi");
|
jest.mock("@/features/Events/EventApi");
|
||||||
jest.mock("@/features/Events/eventUtils");
|
jest.mock("@/features/Events/utils");
|
||||||
jest.mock("@/utils/apiUtils");
|
jest.mock("@/utils/apiUtils");
|
||||||
|
|
||||||
describe("CalendarSlice", () => {
|
describe("CalendarSlice", () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
searchEvent,
|
searchEvent,
|
||||||
} from "@/features/Events/EventApi";
|
} from "@/features/Events/EventApi";
|
||||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
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 { clientConfig } from "@/features/User/oidcAuth";
|
||||||
import { api } from "@/utils/apiUtils";
|
import { api } from "@/utils/apiUtils";
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
normalizeRepetition,
|
normalizeRepetition,
|
||||||
normalizeTimezone,
|
normalizeTimezone,
|
||||||
parseCalendarEvent,
|
parseCalendarEvent,
|
||||||
} from "@/features/Events/eventUtils";
|
} from "@/features/Events/utils";
|
||||||
|
|
||||||
describe("parseCalendarEvent", () => {
|
describe("parseCalendarEvent", () => {
|
||||||
const baseColor = { light: "#00FF00" };
|
const baseColor = { light: "#00FF00" };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
||||||
import { buildDelegatedEventURL } from "@/features/Events/eventUtils";
|
import { buildDelegatedEventURL } from "@/features/Events/utils";
|
||||||
|
|
||||||
const makeCalendar = (link: string): Calendar =>
|
const makeCalendar = (link: string): Calendar =>
|
||||||
({
|
({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||||
import { buildDelegatedEventURL } from "@/features/Events/eventUtils";
|
import { buildDelegatedEventURL } from "@/features/Events/utils";
|
||||||
import { toRejectedError } from "@/utils/errorUtils";
|
import { toRejectedError } from "@/utils/errorUtils";
|
||||||
import { createAsyncThunk } from "@reduxjs/toolkit";
|
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||||
import pMap from "p-map";
|
import pMap from "p-map";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defaultColors } from "@/utils/defaultColors";
|
|
||||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
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 { CalDavItem } from "../api/types";
|
||||||
import { Calendar } from "../CalendarTypes";
|
import { Calendar } from "../CalendarTypes";
|
||||||
import { VCalComponent } from "../types/CalendarData";
|
import { VCalComponent } from "../types/CalendarData";
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
makeTimezone,
|
makeTimezone,
|
||||||
makeVevent,
|
makeVevent,
|
||||||
parseCalendarEvent,
|
parseCalendarEvent,
|
||||||
} from "./eventUtils";
|
} from "./utils";
|
||||||
|
|
||||||
export async function reportEvent(
|
export async function reportEvent(
|
||||||
event: CalendarEvent,
|
event: CalendarEvent,
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ import { Calendar } from "../Calendars/CalendarTypes";
|
|||||||
import { putEventAsync } from "../Calendars/services";
|
import { putEventAsync } from "../Calendars/services";
|
||||||
import { userAttendee } from "../User/models/attendee";
|
import { userAttendee } from "../User/models/attendee";
|
||||||
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||||
import { buildDelegatedEventURL } from "./eventUtils";
|
|
||||||
import { useEventOrganizer } from "./useEventOrganizer";
|
import { useEventOrganizer } from "./useEventOrganizer";
|
||||||
|
import { buildDelegatedEventURL } from "./utils/buildDelegatedEventURL";
|
||||||
|
|
||||||
function EventPopover({
|
function EventPopover({
|
||||||
open,
|
open,
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ import { Calendar } from "../Calendars/CalendarTypes";
|
|||||||
import { userAttendee } from "../User/models/attendee";
|
import { userAttendee } from "../User/models/attendee";
|
||||||
import { deleteEvent, getEvent, putEvent } from "./EventApi";
|
import { deleteEvent, getEvent, putEvent } from "./EventApi";
|
||||||
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||||
import { detectRecurringEventChanges } from "./eventUtils";
|
|
||||||
import { moveEventBetweenCalendars } from "./updateEventHelpers/moveEventBetweenCalendars";
|
import { moveEventBetweenCalendars } from "./updateEventHelpers/moveEventBetweenCalendars";
|
||||||
|
import { detectRecurringEventChanges } from "./utils/detectRecurringEventChanges";
|
||||||
|
|
||||||
function EventUpdateModal({
|
function EventUpdateModal({
|
||||||
eventId,
|
eventId,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "../../Calendars/types/CalendarData";
|
} from "../../Calendars/types/CalendarData";
|
||||||
import { getAllRecurrentEvent } from "../EventApi";
|
import { getAllRecurrentEvent } from "../EventApi";
|
||||||
import { CalendarEvent } from "../EventsTypes";
|
import { CalendarEvent } from "../EventsTypes";
|
||||||
import { makeTimezone, makeVevent } from "../eventUtils";
|
import { makeTimezone, makeVevent } from "../utils";
|
||||||
|
|
||||||
const METADATA_FIELDS = [
|
const METADATA_FIELDS = [
|
||||||
"summary",
|
"summary",
|
||||||
|
|||||||
@@ -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 { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
|
||||||
import { makeDisplayName } from "@/utils/makeDisplayName";
|
import { makeDisplayName } from "@/utils/makeDisplayName";
|
||||||
import { CalendarEvent } from "../EventsTypes";
|
import { CalendarEvent } from "../EventsTypes";
|
||||||
import { buildDelegatedEventURL } from "../eventUtils";
|
import { buildDelegatedEventURL } from "../utils/buildDelegatedEventURL";
|
||||||
|
|
||||||
export interface MoveEventBetweenCalendarsParams {
|
export interface MoveEventBetweenCalendarsParams {
|
||||||
dispatch: AppDispatch;
|
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`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user