* [#352] update exceptions on all update without reseting them * [#352] added tests
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { updateSeries } from "@/features/Events/api/updateSeries";
|
||||
import {
|
||||
deleteEvent,
|
||||
importEventFromFile,
|
||||
@@ -271,4 +272,511 @@ describe("eventApi", () => {
|
||||
expect(body.participants).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSeries", () => {
|
||||
const recurringEventICS = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
DTSTART:20240201T100000Z
|
||||
DTEND:20240201T110000Z
|
||||
RRULE:FREQ=DAILY
|
||||
SUMMARY:Old title
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
RECURRENCE-ID:20240202T100000Z
|
||||
DTSTART:20240202T100000Z
|
||||
DTEND:20240202T110000Z
|
||||
SUMMARY:Old title
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
`;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
(api.get as jest.Mock).mockResolvedValue({
|
||||
text: jest.fn().mockResolvedValue(recurringEventICS),
|
||||
});
|
||||
|
||||
(api as jest.Mock).mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("propagates summary changes to overrides", async () => {
|
||||
await updateSeries(
|
||||
{ ...mockEvent, title: "New title" } as any,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body);
|
||||
const vevents = body[2].filter(([n]: any) => n === "vevent");
|
||||
|
||||
const override = vevents.find(([, props]: any) =>
|
||||
props.some(([k]: any) => k === "recurrence-id")
|
||||
);
|
||||
|
||||
const summary = override[1].find(([k]: any) => k === "summary");
|
||||
expect(summary[3]).toBe("New title");
|
||||
});
|
||||
|
||||
it("does not remove recurrence-id from overrides", async () => {
|
||||
await updateSeries(mockEvent as any, undefined, false);
|
||||
|
||||
const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body);
|
||||
const vevents = body[2].filter(([n]: any) => n === "vevent");
|
||||
|
||||
const override = vevents.find(([, props]: any) =>
|
||||
props.some(([k]: any) => k === "recurrence-id")
|
||||
);
|
||||
|
||||
expect(override).toBeDefined();
|
||||
});
|
||||
|
||||
it("increments SEQUENCE on overrides when metadata changes", async () => {
|
||||
await updateSeries(
|
||||
{ ...mockEvent, title: "Changed title" } as any,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body);
|
||||
const vevents = body[2].filter(([n]: any) => n === "vevent");
|
||||
|
||||
const override = vevents.find(([, props]: any) =>
|
||||
props.some(([k]: any) => k === "recurrence-id")
|
||||
);
|
||||
|
||||
const sequence = override[1].find(([k]: any) => k === "sequence");
|
||||
expect(sequence[3]).toBe(2);
|
||||
});
|
||||
|
||||
it("propagates description, location, class, and transp changes", async () => {
|
||||
const icsWithMultipleFields = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
DTSTART:20240201T100000Z
|
||||
DTEND:20240201T110000Z
|
||||
RRULE:FREQ=DAILY
|
||||
SUMMARY:Title
|
||||
DESCRIPTION:Old description
|
||||
LOCATION:Old location
|
||||
CLASS:PUBLIC
|
||||
TRANSP:OPAQUE
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
RECURRENCE-ID:20240202T100000Z
|
||||
DTSTART:20240202T100000Z
|
||||
DTEND:20240202T110000Z
|
||||
SUMMARY:Title
|
||||
DESCRIPTION:Old description
|
||||
LOCATION:Old location
|
||||
CLASS:PUBLIC
|
||||
TRANSP:OPAQUE
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
`;
|
||||
|
||||
(api.get as jest.Mock).mockResolvedValueOnce({
|
||||
text: jest.fn().mockResolvedValue(icsWithMultipleFields),
|
||||
});
|
||||
|
||||
await updateSeries(
|
||||
{
|
||||
...mockEvent,
|
||||
description: "New description",
|
||||
location: "New location",
|
||||
class: "PRIVATE",
|
||||
transp: "TRANSPARENT",
|
||||
} as any,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body);
|
||||
const vevents = body[2].filter(([n]: any) => n === "vevent");
|
||||
const override = vevents.find(([, props]: any) =>
|
||||
props.some(([k]: any) => k === "recurrence-id")
|
||||
);
|
||||
|
||||
const description = override[1].find(([k]: any) => k === "description");
|
||||
const location = override[1].find(([k]: any) => k === "location");
|
||||
const classField = override[1].find(([k]: any) => k === "class");
|
||||
const transp = override[1].find(([k]: any) => k === "transp");
|
||||
|
||||
expect(description[3]).toBe("New description");
|
||||
expect(location[3]).toBe("New location");
|
||||
expect(classField[3]).toBe("PRIVATE");
|
||||
expect(transp[3]).toBe("TRANSPARENT");
|
||||
});
|
||||
|
||||
it("propagates organizer changes", async () => {
|
||||
const icsWithOrganizer = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
DTSTART:20240201T100000Z
|
||||
RRULE:FREQ=DAILY
|
||||
SUMMARY:Title
|
||||
ORGANIZER;CN=Alice:mailto:alice@example.com
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
RECURRENCE-ID:20240202T100000Z
|
||||
DTSTART:20240202T100000Z
|
||||
SUMMARY:Title
|
||||
ORGANIZER;CN=Alice:mailto:alice@example.com
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
`;
|
||||
|
||||
(api.get as jest.Mock).mockResolvedValueOnce({
|
||||
text: jest.fn().mockResolvedValue(icsWithOrganizer),
|
||||
});
|
||||
|
||||
await updateSeries(
|
||||
{
|
||||
...mockEvent,
|
||||
organizer: { cn: "Bob", cal_address: "bob@example.com" },
|
||||
} as any,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body);
|
||||
const vevents = body[2].filter(([n]: any) => n === "vevent");
|
||||
const override = vevents.find(([, props]: any) =>
|
||||
props.some(([k]: any) => k === "recurrence-id")
|
||||
);
|
||||
|
||||
const organizer = override[1].find(([k]: any) => k === "organizer");
|
||||
expect(organizer[3]).toBe("mailto:bob@example.com");
|
||||
expect(organizer[1].cn).toBe("Bob");
|
||||
});
|
||||
|
||||
it("propagates attendee changes", async () => {
|
||||
const icsWithAttendees = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
DTSTART:20240201T100000Z
|
||||
RRULE:FREQ=DAILY
|
||||
SUMMARY:Title
|
||||
ATTENDEE;CN=Alice:mailto:alice@example.com
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
RECURRENCE-ID:20240202T100000Z
|
||||
DTSTART:20240202T100000Z
|
||||
SUMMARY:Title
|
||||
ATTENDEE;CN=Alice:mailto:alice@example.com
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
`;
|
||||
|
||||
(api.get as jest.Mock).mockResolvedValueOnce({
|
||||
text: jest.fn().mockResolvedValue(icsWithAttendees),
|
||||
});
|
||||
|
||||
await updateSeries(
|
||||
{
|
||||
...mockEvent,
|
||||
attendee: [
|
||||
{
|
||||
cn: "Bob",
|
||||
cal_address: "bob@example.com",
|
||||
partstat: "NEEDS-ACTION",
|
||||
cutype: "INDIVIDUAL",
|
||||
role: "REQ-PARTICIPANT",
|
||||
rsvp: "TRUE",
|
||||
},
|
||||
{
|
||||
cn: "Charlie",
|
||||
cal_address: "charlie@example.com",
|
||||
partstat: "NEEDS-ACTION",
|
||||
cutype: "INDIVIDUAL",
|
||||
role: "REQ-PARTICIPANT",
|
||||
rsvp: "TRUE",
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body);
|
||||
const vevents = body[2].filter(([n]: any) => n === "vevent");
|
||||
const override = vevents.find(([, props]: any) =>
|
||||
props.some(([k]: any) => k === "recurrence-id")
|
||||
);
|
||||
|
||||
const attendees = override[1].filter(([k]: any) => k === "attendee");
|
||||
expect(attendees).toHaveLength(2);
|
||||
expect(attendees[0][3]).toBe("mailto:bob@example.com");
|
||||
expect(attendees[1][3]).toBe("mailto:charlie@example.com");
|
||||
});
|
||||
|
||||
it("propagates x-openpaas-videoconference changes", async () => {
|
||||
const icsWithVideo = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
DTSTART:20240201T100000Z
|
||||
RRULE:FREQ=DAILY
|
||||
SUMMARY:Title
|
||||
X-OPENPAAS-VIDEOCONFERENCE:https://meet.old.com
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
RECURRENCE-ID:20240202T100000Z
|
||||
DTSTART:20240202T100000Z
|
||||
SUMMARY:Title
|
||||
X-OPENPAAS-VIDEOCONFERENCE:https://meet.old.com
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
`;
|
||||
|
||||
(api.get as jest.Mock).mockResolvedValueOnce({
|
||||
text: jest.fn().mockResolvedValue(icsWithVideo),
|
||||
});
|
||||
|
||||
await updateSeries(
|
||||
{
|
||||
...mockEvent,
|
||||
x_openpass_videoconference: "https://meet.new.com",
|
||||
} as any,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body);
|
||||
const vevents = body[2].filter(([n]: any) => n === "vevent");
|
||||
const override = vevents.find(([, props]: any) =>
|
||||
props.some(([k]: any) => k === "recurrence-id")
|
||||
);
|
||||
|
||||
const videoconf = override[1].find(
|
||||
([k]: any) => k === "x-openpaas-videoconference"
|
||||
);
|
||||
expect(videoconf[3]).toBe("https://meet.new.com");
|
||||
});
|
||||
|
||||
it("works with multiple override instances", async () => {
|
||||
const icsMultipleOverrides = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
DTSTART:20240201T100000Z
|
||||
DTEND:20240201T110000Z
|
||||
RRULE:FREQ=DAILY
|
||||
SUMMARY:Old
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
RECURRENCE-ID:20240202T100000Z
|
||||
DTSTART:20240202T100000Z
|
||||
DTEND:20240202T110000Z
|
||||
SUMMARY:Old
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
RECURRENCE-ID:20240203T100000Z
|
||||
DTSTART:20240203T100000Z
|
||||
DTEND:20240203T110000Z
|
||||
SUMMARY:Old
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
`;
|
||||
|
||||
(api.get as jest.Mock).mockResolvedValueOnce({
|
||||
text: jest.fn().mockResolvedValue(icsMultipleOverrides),
|
||||
});
|
||||
|
||||
await updateSeries(
|
||||
{ ...mockEvent, title: "New title" } as any,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body);
|
||||
const vevents = body[2].filter(([n]: any) => n === "vevent");
|
||||
const overrides = vevents.filter(([, props]: any) =>
|
||||
props.some(([k]: any) => k === "recurrence-id")
|
||||
);
|
||||
|
||||
expect(overrides).toHaveLength(2);
|
||||
overrides.forEach((override) => {
|
||||
const summary = override[1].find(([k]: any) => k === "summary");
|
||||
const sequence = override[1].find(([k]: any) => k === "sequence");
|
||||
expect(summary[3]).toBe("New title");
|
||||
expect(sequence[3]).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("adds sequence field when missing and metadata changes", async () => {
|
||||
const icsNoSequence = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
DTSTART:20240201T100000Z
|
||||
DTEND:20240201T110000Z
|
||||
RRULE:FREQ=DAILY
|
||||
SUMMARY:Old
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
RECURRENCE-ID:20240202T100000Z
|
||||
DTSTART:20240202T100000Z
|
||||
DTEND:20240202T110000Z
|
||||
SUMMARY:Old
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
`;
|
||||
|
||||
(api.get as jest.Mock).mockResolvedValueOnce({
|
||||
text: jest.fn().mockResolvedValue(icsNoSequence),
|
||||
});
|
||||
|
||||
await updateSeries(
|
||||
{ ...mockEvent, title: "New title" } as any,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body);
|
||||
const vevents = body[2].filter(([n]: any) => n === "vevent");
|
||||
const override = vevents.find(([, props]: any) =>
|
||||
props.some(([k]: any) => k === "recurrence-id")
|
||||
);
|
||||
|
||||
const sequence = override[1].find(([k]: any) => k === "sequence");
|
||||
expect(sequence[3]).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves override-specific fields when propagating metadata", async () => {
|
||||
const icsCustomFields = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
DTSTART:20240201T100000Z
|
||||
DTEND:20240201T110000Z
|
||||
RRULE:FREQ=DAILY
|
||||
SUMMARY:Old
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
RECURRENCE-ID:20240202T100000Z
|
||||
DTSTART:20240202T140000Z
|
||||
DTEND:20240202T150000Z
|
||||
SUMMARY:Old
|
||||
DESCRIPTION:custom-value
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
`;
|
||||
|
||||
(api.get as jest.Mock).mockResolvedValueOnce({
|
||||
text: jest.fn().mockResolvedValue(icsCustomFields),
|
||||
});
|
||||
|
||||
await updateSeries(
|
||||
{ ...mockEvent, title: "New title" } as any,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body);
|
||||
const vevents = body[2].filter(([n]: any) => n === "vevent");
|
||||
const override = vevents.find(([, props]: any) =>
|
||||
props.some(([k]: any) => k === "recurrence-id")
|
||||
);
|
||||
|
||||
const summary = override[1].find(([k]: any) => k === "summary");
|
||||
const dtstart = override[1].find(([k]: any) => k === "dtstart");
|
||||
const dtend = override[1].find(([k]: any) => k === "dtend");
|
||||
const description = override[1].find(([k]: any) => k === "description");
|
||||
const recurrenceId = override[1].find(
|
||||
([k]: any) => k === "recurrence-id"
|
||||
);
|
||||
// Summary should be updated
|
||||
expect(summary[3]).toBe("New title");
|
||||
// Override-specific time should be preserved
|
||||
expect(dtstart[3]).toBe("2024-02-02T14:00:00Z");
|
||||
expect(dtend[3]).toBe("2024-02-02T15:00:00Z");
|
||||
// Custom field should be preserved
|
||||
expect(description[3]).toBe("custom-value");
|
||||
// Recurrence-id should be preserved
|
||||
expect(recurrenceId[3]).toBe("2024-02-02T10:00:00Z");
|
||||
});
|
||||
|
||||
const recurringWithAlarmICS = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
DTSTART:20240201T100000Z
|
||||
RRULE:FREQ=DAILY
|
||||
SUMMARY:Old
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
TRIGGER:-PT15M
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:event1
|
||||
RECURRENCE-ID:20240202T100000Z
|
||||
DTSTART:20240202T100000Z
|
||||
SUMMARY:Old
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
TRIGGER:-PT15M
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
`;
|
||||
|
||||
it("replaces VALARM on overrides when alarm changes", async () => {
|
||||
(api.get as jest.Mock).mockResolvedValueOnce({
|
||||
text: jest.fn().mockResolvedValue(recurringWithAlarmICS),
|
||||
});
|
||||
|
||||
await updateSeries(
|
||||
{ ...mockEvent, alarm: { action: "EMAIL", trigger: "-PT30M" } } as any,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body);
|
||||
const vevents = body[2].filter(([n]: any) => n === "vevent");
|
||||
|
||||
const override = vevents.find(([, props]: any) =>
|
||||
props.some(([k]: any) => k === "recurrence-id")
|
||||
);
|
||||
|
||||
const alarms = override[2].filter(([n]: any) => n === "valarm");
|
||||
|
||||
expect(alarms).toHaveLength(1);
|
||||
const action = alarms[0][1].find(([k]: any) => k === "action");
|
||||
const trigger = alarms[0][1].find(([k]: any) => k === "trigger");
|
||||
expect(action[3]).toBe("EMAIL");
|
||||
expect(trigger[3]).toBe("-PT30M");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { renderWithProviders } from "../../utils/Renderwithproviders";
|
||||
|
||||
jest.mock("@/features/Events/EventApi");
|
||||
jest.mock("@/features/Calendars/CalendarApi");
|
||||
jest.mock("@/features/Events/api/updateSeries");
|
||||
|
||||
describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { updateSeries } from "@/features/Events/EventApi";
|
||||
import { updateSeries } from "@/features/Events/api/updateSeries";
|
||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||
import { toRejectedError } from "@/utils/errorUtils";
|
||||
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||
|
||||
@@ -22,17 +22,12 @@ export type VObjectValue =
|
||||
// VObject property tuple
|
||||
export type VObjectProperty = [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
string | Array<unknown>,
|
||||
Record<string, VObjectValue | VObjectValue[]>,
|
||||
string,
|
||||
VObjectValue,
|
||||
];
|
||||
|
||||
export type VCalComponent = [
|
||||
string,
|
||||
VObjectProperty[],
|
||||
VCalComponent[],
|
||||
...unknown[],
|
||||
];
|
||||
export type VCalComponent = [string, VObjectProperty[], VCalComponent[]?];
|
||||
|
||||
export interface Organizer {
|
||||
cn?: string;
|
||||
@@ -44,12 +39,7 @@ export interface CalendarItem {
|
||||
_links: CalDavLink;
|
||||
etag: string;
|
||||
status: number;
|
||||
data: [
|
||||
"vcalendar",
|
||||
Array<
|
||||
VObjectProperty | [string, VObjectProperty[], unknown[]] // vevent array
|
||||
>,
|
||||
];
|
||||
data: VCalComponent;
|
||||
}
|
||||
|
||||
// Main calendar data
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { api } from "@/utils/apiUtils";
|
||||
import { resolveTimezoneId, convertEventDateTimeToISO } from "@/utils/timezone";
|
||||
import { convertEventDateTimeToISO, resolveTimezoneId } from "@/utils/timezone";
|
||||
import { TIMEZONES } from "@/utils/timezone-data";
|
||||
import ICAL from "ical.js";
|
||||
import { CalDavItem } from "../Calendars/api/types";
|
||||
import {
|
||||
VCalComponent,
|
||||
VObjectProperty,
|
||||
VObjectValue,
|
||||
} from "../Calendars/types/CalendarData";
|
||||
import { SearchEventsResponse } from "../Search/types/SearchEventsResponse";
|
||||
import { CalendarEvent } from "./EventsTypes";
|
||||
import {
|
||||
@@ -12,23 +17,6 @@ import {
|
||||
parseCalendarEvent,
|
||||
} from "./eventUtils";
|
||||
|
||||
type JCalValue = string | number | boolean | null;
|
||||
|
||||
type JCalParams = Record<string, JCalValue | JCalValue[]>;
|
||||
|
||||
type JCalProperty = [
|
||||
name: string,
|
||||
params: JCalParams,
|
||||
type: string,
|
||||
value: JCalValue,
|
||||
];
|
||||
|
||||
type JCalComponent = [
|
||||
name: string,
|
||||
properties: JCalProperty[],
|
||||
components?: JCalComponent[],
|
||||
];
|
||||
|
||||
export async function reportEvent(
|
||||
event: CalendarEvent,
|
||||
match: { start: string; end: string }
|
||||
@@ -61,7 +49,7 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
||||
let targetVevent;
|
||||
if (isMaster) {
|
||||
targetVevent = vevents.find(
|
||||
([, props]: JCalComponent) =>
|
||||
([, props]: VCalComponent) =>
|
||||
!props.find(([k]) => k.toLowerCase() === "recurrence-id")
|
||||
);
|
||||
if (!targetVevent) {
|
||||
@@ -222,7 +210,7 @@ export const deleteEventInstance = async (event: CalendarEvent) => {
|
||||
|
||||
// Find the master VEVENT
|
||||
const masterIndex = vevents.findIndex(
|
||||
([, props]: JCalComponent) =>
|
||||
([, props]: VCalComponent) =>
|
||||
!props.find(([k]) => k.toLowerCase() === "recurrence-id")
|
||||
);
|
||||
|
||||
@@ -235,9 +223,9 @@ export const deleteEventInstance = async (event: CalendarEvent) => {
|
||||
const masterProps = vevents[masterIndex][1];
|
||||
|
||||
// Check if this date is already in EXDATE (avoid duplicates)
|
||||
const normalizeRecurrenceId = (id: JCalValue) =>
|
||||
const normalizeRecurrenceId = (id: VObjectValue) =>
|
||||
String(id ?? "").replace(/Z$/, "");
|
||||
const isDuplicate = masterProps.some((prop: JCalProperty) => {
|
||||
const isDuplicate = masterProps.some((prop: VObjectProperty) => {
|
||||
if (prop[0].toLowerCase() === "exdate" && prop[3]) {
|
||||
return (
|
||||
normalizeRecurrenceId(prop[3]) === normalizeRecurrenceId(exdateValue)
|
||||
@@ -256,7 +244,7 @@ export const deleteEventInstance = async (event: CalendarEvent) => {
|
||||
vevents[masterIndex][1] = masterProps;
|
||||
|
||||
// Remove the override instance if it exists (in case it was an override being deleted)
|
||||
const filteredVevents = vevents.filter(([, props]: JCalComponent) => {
|
||||
const filteredVevents = vevents.filter(([, props]: VCalComponent) => {
|
||||
const recurrenceIdProp = props.find(
|
||||
([k]) => k.toLowerCase() === "recurrence-id"
|
||||
);
|
||||
@@ -294,12 +282,12 @@ export const updateSeriesPartstat = async (
|
||||
const vevents = await getAllRecurrentEvent(event);
|
||||
|
||||
// Update PARTSTAT in ALL VEVENTs (master + exceptions)
|
||||
const updatedVevents = vevents.map((vevent: JCalComponent) => {
|
||||
const updatedVevents = vevents.map((vevent: VCalComponent) => {
|
||||
const properties = vevent[1];
|
||||
const updatedProperties = properties.map((prop: JCalProperty) => {
|
||||
const updatedProperties = properties.map((prop: VObjectProperty) => {
|
||||
// Find ATTENDEE properties
|
||||
if (prop[0] === "attendee") {
|
||||
const calAddress = prop[3];
|
||||
const calAddress = prop[3] as string;
|
||||
// Check if this is the target attendee
|
||||
if (calAddress.toLowerCase().includes(attendeeEmail.toLowerCase())) {
|
||||
// Update PARTSTAT parameter
|
||||
@@ -330,57 +318,7 @@ export const updateSeriesPartstat = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSeries = async (
|
||||
event: CalendarEvent,
|
||||
calOwnerEmail?: string,
|
||||
removeOverrides: boolean = true
|
||||
) => {
|
||||
const vevents = await getAllRecurrentEvent(event);
|
||||
const masterIndex = vevents.findIndex(
|
||||
([, props]: JCalComponent) =>
|
||||
!props.find(([k]) => k.toLowerCase() === "recurrence-id")
|
||||
);
|
||||
if (masterIndex === -1) {
|
||||
throw new Error("No master VEVENT found for this series");
|
||||
}
|
||||
const rrule = vevents[masterIndex][1].find(([k]: string[]) => k === "rrule");
|
||||
|
||||
const tzid = event.timezone;
|
||||
|
||||
const updatedMaster = makeVevent(event, tzid, calOwnerEmail, true);
|
||||
const newRrule = updatedMaster[1].find(([k]: string[]) => k === "rrule");
|
||||
if (!newRrule) {
|
||||
updatedMaster[1].push(rrule);
|
||||
}
|
||||
|
||||
const timezoneData = TIMEZONES.zones[event.timezone];
|
||||
const vtimezone = makeTimezone(timezoneData, event);
|
||||
|
||||
let finalVevents;
|
||||
if (removeOverrides) {
|
||||
// When date/time/timezone/repeat rules changed, remove all override instances
|
||||
finalVevents = [updatedMaster];
|
||||
} else {
|
||||
// When only properties changed, keep override instances
|
||||
vevents[masterIndex] = updatedMaster;
|
||||
finalVevents = vevents;
|
||||
}
|
||||
|
||||
const newJCal = [
|
||||
"vcalendar",
|
||||
[],
|
||||
[...finalVevents, vtimezone.component.jCal],
|
||||
];
|
||||
return api(`dav${event.URL}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(newJCal),
|
||||
headers: {
|
||||
"content-type": "text/calendar; charset=utf-8",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
async function getAllRecurrentEvent(event: CalendarEvent) {
|
||||
export async function getAllRecurrentEvent(event: CalendarEvent) {
|
||||
const response = await api.get(`dav${event.URL}`);
|
||||
const eventData = await response.text();
|
||||
const jcal = ICAL.parse(eventData);
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { api } from "@/utils/apiUtils";
|
||||
import { TIMEZONES } from "@/utils/timezone-data";
|
||||
import {
|
||||
VCalComponent,
|
||||
VObjectProperty,
|
||||
} from "../../Calendars/types/CalendarData";
|
||||
import { getAllRecurrentEvent } from "../EventApi";
|
||||
import { CalendarEvent } from "../EventsTypes";
|
||||
import { makeTimezone, makeVevent } from "../eventUtils";
|
||||
|
||||
const METADATA_FIELDS = [
|
||||
"summary",
|
||||
"description",
|
||||
"location",
|
||||
"class",
|
||||
"transp",
|
||||
"attendee",
|
||||
"organizer",
|
||||
"x-openpaas-videoconference",
|
||||
] as const;
|
||||
|
||||
// Helper function to get field values from props
|
||||
const getFieldValues = (props: VObjectProperty[], fieldName: string) => {
|
||||
return props.filter(([k]) => k.toLowerCase() === fieldName.toLowerCase());
|
||||
};
|
||||
|
||||
// Helper function to find a single field value from props
|
||||
const findFieldValue = (props: VObjectProperty[], fieldName: string) => {
|
||||
return props.find(([k]) => k.toLowerCase() === fieldName.toLowerCase());
|
||||
};
|
||||
|
||||
// Helper function to serialize for comparison
|
||||
const serialize = (values: VObjectProperty[] | VCalComponent[]) => {
|
||||
return JSON.stringify(values);
|
||||
};
|
||||
|
||||
// Helper function to filter components by name
|
||||
const filterComponentsByName = (components: VCalComponent[], name: string) => {
|
||||
return components.filter(
|
||||
([componentName]) => componentName.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
// Detect which metadata fields changed between old and new master
|
||||
const detectChangedMetadataFields = (
|
||||
oldMasterProps: VObjectProperty[],
|
||||
newMasterProps: VObjectProperty[]
|
||||
) => {
|
||||
const changedFields = new Map<string, VObjectProperty[]>();
|
||||
|
||||
METADATA_FIELDS.forEach((fieldName) => {
|
||||
const oldValues = getFieldValues(oldMasterProps, fieldName);
|
||||
const newValues = getFieldValues(newMasterProps, fieldName);
|
||||
|
||||
if (serialize(oldValues) !== serialize(newValues)) {
|
||||
changedFields.set(fieldName.toLowerCase(), newValues);
|
||||
}
|
||||
});
|
||||
|
||||
return changedFields;
|
||||
};
|
||||
|
||||
// Check if VALARM component changed between old and new master
|
||||
const detectValarmChanges = (
|
||||
oldMaster: VCalComponent,
|
||||
updatedMaster: VCalComponent
|
||||
) => {
|
||||
const oldMasterComponents = oldMaster[2] || [];
|
||||
const newMasterComponents = updatedMaster[2] || [];
|
||||
|
||||
const oldValarm = filterComponentsByName(oldMasterComponents, "valarm");
|
||||
const newValarm = filterComponentsByName(newMasterComponents, "valarm");
|
||||
|
||||
const valarmChanged = serialize(oldValarm) !== serialize(newValarm);
|
||||
|
||||
return { valarmChanged, newValarm };
|
||||
};
|
||||
|
||||
// Apply changed metadata fields to a vevent's properties
|
||||
const applyMetadataChanges = (
|
||||
props: VObjectProperty[],
|
||||
changedFields: Map<string, VObjectProperty[]>
|
||||
) => {
|
||||
let newProps = [...props];
|
||||
|
||||
changedFields.forEach((newValues, fieldNameLower) => {
|
||||
// Remove old values of this changed field from exception
|
||||
const filteredProps = newProps.filter(
|
||||
([k]) => k.toLowerCase() !== fieldNameLower
|
||||
);
|
||||
|
||||
// Add new values from updated master
|
||||
newProps = [...filteredProps, ...newValues];
|
||||
});
|
||||
|
||||
return newProps;
|
||||
};
|
||||
|
||||
// Increment the sequence number in properties
|
||||
const incrementSequenceNumber = (props: VObjectProperty[]) => {
|
||||
const newProps = [...props];
|
||||
const sequenceIndex = newProps.findIndex(
|
||||
([k]) => k.toLowerCase() === "sequence"
|
||||
);
|
||||
|
||||
if (sequenceIndex !== -1) {
|
||||
const currentSequence = parseInt(
|
||||
(newProps[sequenceIndex][3] as string) || "0",
|
||||
10
|
||||
);
|
||||
newProps[sequenceIndex] = [
|
||||
newProps[sequenceIndex][0],
|
||||
newProps[sequenceIndex][1],
|
||||
newProps[sequenceIndex][2],
|
||||
currentSequence + 1,
|
||||
];
|
||||
} else {
|
||||
newProps.push(["sequence", {}, "integer", 1]);
|
||||
}
|
||||
|
||||
return newProps;
|
||||
};
|
||||
|
||||
// Update VALARM components if they changed
|
||||
const updateValarmComponents = (
|
||||
components: VCalComponent[],
|
||||
newValarm: VCalComponent[]
|
||||
) => {
|
||||
return components
|
||||
.filter(([name]) => name.toLowerCase() !== "valarm")
|
||||
.concat(newValarm);
|
||||
};
|
||||
|
||||
// Update a single vevent with metadata changes
|
||||
const updateVeventWithMetadataChanges = (
|
||||
vevent: VCalComponent,
|
||||
index: number,
|
||||
masterIndex: number,
|
||||
updatedMaster: VCalComponent,
|
||||
changedFields: Map<string, VObjectProperty[]>,
|
||||
valarmChanged: boolean,
|
||||
newValarm: VCalComponent[]
|
||||
): VCalComponent => {
|
||||
if (index === masterIndex) {
|
||||
return updatedMaster;
|
||||
}
|
||||
|
||||
const [veventType, props, components = []] = vevent;
|
||||
|
||||
// Apply metadata changes
|
||||
let newProps = applyMetadataChanges(props, changedFields);
|
||||
|
||||
// Increment sequence number if any changes were made
|
||||
if (changedFields.size > 0 || valarmChanged) {
|
||||
newProps = incrementSequenceNumber(newProps);
|
||||
}
|
||||
|
||||
// Handle VALARM component updates
|
||||
let updatedComponents = components;
|
||||
if (valarmChanged) {
|
||||
updatedComponents = updateValarmComponents(components, newValarm);
|
||||
}
|
||||
|
||||
return [veventType, newProps, updatedComponents];
|
||||
};
|
||||
|
||||
// Update all vevents with metadata changes while preserving overrides
|
||||
const updateVeventsPreservingOverrides = (
|
||||
vevents: VCalComponent[],
|
||||
oldMaster: VCalComponent,
|
||||
updatedMaster: VCalComponent,
|
||||
masterIndex: number
|
||||
) => {
|
||||
const oldMasterProps = oldMaster[1];
|
||||
const newMasterProps = updatedMaster[1];
|
||||
|
||||
// Detect which fields changed in the master
|
||||
const changedFields = detectChangedMetadataFields(
|
||||
oldMasterProps,
|
||||
newMasterProps
|
||||
);
|
||||
|
||||
// Check if VALARM component changed
|
||||
const { valarmChanged, newValarm } = detectValarmChanges(
|
||||
oldMaster,
|
||||
updatedMaster
|
||||
);
|
||||
|
||||
// Update all vevents
|
||||
return vevents.map((vevent, index) =>
|
||||
updateVeventWithMetadataChanges(
|
||||
vevent,
|
||||
index,
|
||||
masterIndex,
|
||||
updatedMaster,
|
||||
changedFields,
|
||||
valarmChanged,
|
||||
newValarm
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const updateSeries = async (
|
||||
event: CalendarEvent,
|
||||
calOwnerEmail?: string,
|
||||
removeOverrides: boolean = true
|
||||
) => {
|
||||
const vevents = (await getAllRecurrentEvent(event)) as VCalComponent[];
|
||||
const masterIndex = vevents.findIndex(
|
||||
([, props]) => !findFieldValue(props, "recurrence-id")
|
||||
);
|
||||
|
||||
if (masterIndex === -1) {
|
||||
throw new Error("No master VEVENT found for this series");
|
||||
}
|
||||
|
||||
const rrule = findFieldValue(vevents[masterIndex][1], "rrule");
|
||||
const tzid = event.timezone;
|
||||
const oldMaster = vevents[masterIndex];
|
||||
|
||||
const updatedMaster = makeVevent(
|
||||
event,
|
||||
tzid,
|
||||
calOwnerEmail,
|
||||
true
|
||||
) as VCalComponent;
|
||||
const newRrule = findFieldValue(updatedMaster[1], "rrule");
|
||||
if (!newRrule && rrule) {
|
||||
updatedMaster[1].push(rrule!);
|
||||
}
|
||||
|
||||
const timezoneData = TIMEZONES.zones[event.timezone];
|
||||
const vtimezone = makeTimezone(timezoneData, event);
|
||||
|
||||
let finalVevents: VCalComponent[];
|
||||
|
||||
if (removeOverrides) {
|
||||
// When date/time/timezone/repeat rules changed, remove all override instances
|
||||
finalVevents = [updatedMaster];
|
||||
} else {
|
||||
// When only properties changed, keep override instances and update their metadata
|
||||
finalVevents = updateVeventsPreservingOverrides(
|
||||
vevents,
|
||||
oldMaster,
|
||||
updatedMaster,
|
||||
masterIndex
|
||||
);
|
||||
}
|
||||
|
||||
const newJCal = [
|
||||
"vcalendar",
|
||||
[],
|
||||
[...finalVevents, vtimezone.component.jCal],
|
||||
];
|
||||
|
||||
return api(`dav${event.URL}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(newJCal),
|
||||
headers: {
|
||||
"content-type": "text/calendar; charset=utf-8",
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user