[#352] update exceptions on all update without reseting them (#510)

* [#352] update exceptions on all update without reseting them

* [#352] added tests
This commit is contained in:
Camille Moussu
2026-02-11 11:20:10 +01:00
committed by GitHub
parent 6b42597a2c
commit fad1dfe4c5
6 changed files with 792 additions and 92 deletions
+508
View File
@@ -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";
+4 -14
View File
@@ -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
+15 -77
View File
@@ -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);
+263
View File
@@ -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",
},
});
};