mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
A series keeps its changed and cancelled occurrences as overrides keyed by recurrence id, where the rule puts each occurrence. Two edits left them behind (live on 1.0 and 0.16.23, attendees included): - Moving every occurrence kept the overrides under the old ids. A weekly series moved an hour got its cancelled 16 December back, at 11:00, and showed its retitled 30 December twice, at 10:00 and at 11:00; 1.0 doubled it in the attendee's copy as well. - "This and following events" ended the rule but not the overrides after the split, which then showed on as occurrences of the old series next to the new one, and the new series got the whole COUNT again: six weekly occurrences split at the third ran to eight dates, 4 January twice. Deleting "this and following" left the later overrides too. Now: - Moving the series moves each override with its occurrence, in the same update: by as much as the series' start moved, onto the same day at the new time when the series turns all-day or timed. An occurrence moved on its own keeps its time, except the one the editor was opened on, which takes the move made there; an override that only held the time its occurrence had moves along, and so does a pinned length. When the rule changes, overrides of occurrences the new rule no longer has are dropped. When the attendees are asked again, so they are in the overrides that move. - The split removes the overrides from the split on together with the rule change and gives them to the new series, re-keyed and with the new series' participants: 0.16 keeps an override as an event of its own, so one without them reached no attendee. The new series' COUNT is what is left of the old one; an UNTIL stays, and a rule the user changed in the editor is taken as it is. A failed create puts the overrides back with the rule. - Delete "this and following" removes the overrides from there on.
743 lines
33 KiB
TypeScript
743 lines
33 KiB
TypeScript
/**
|
|
* Saving an event editor change made on one occurrence of a series for the
|
|
* part of the series the user picks in the recurrence scope dialog: that
|
|
* occurrence, it and the ones after it, or all of them.
|
|
*/
|
|
|
|
import { differenceInCalendarDays, format, isSameDay, parseISO } from 'date-fns';
|
|
import type { RecurrenceEditScope } from '@/components/calendar/recurrence-scope-dialog';
|
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
|
import type { CalendarEvent } from '@/lib/jmap/types';
|
|
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from '@/lib/calendar-utils';
|
|
import { parseDurationSeconds } from '@/lib/calendar-event-normalization';
|
|
import {
|
|
getParticipantList,
|
|
participantFieldsToSave,
|
|
participantsAskedAgain,
|
|
userOrganizes,
|
|
type ParticipantSaveContext,
|
|
} from '@/lib/calendar-participants';
|
|
import {
|
|
buildFallbackExcludePatch,
|
|
buildFallbackOverridePatch,
|
|
isBrowserExpandedOccurrence,
|
|
isServerRecurrenceInstance,
|
|
overrideContextOf,
|
|
resolveOverrideKey,
|
|
withNewOverrideDetails,
|
|
} from '@/lib/recurrence-instances';
|
|
import { occurrencesBefore, seriesOccursAt } from '@/lib/recurrence-expansion';
|
|
import { sameRecurrenceRules } from '@/lib/recurrence-rule';
|
|
import { getEffectiveTimeZone, moveLocalDateTime } from '@/lib/timezone';
|
|
import { debug } from '@/lib/debug';
|
|
|
|
export interface EditorAttendee {
|
|
name: string;
|
|
email: string;
|
|
}
|
|
|
|
/** When an event happens in the editor: display dates, the last day of an all-day event. */
|
|
export interface EventWhen {
|
|
allDay: boolean;
|
|
start: Date;
|
|
end: Date;
|
|
}
|
|
|
|
/** What the event editor had when it saved, besides the event data it wrote. */
|
|
export interface EventEditorState {
|
|
/** The attendees when the editor opened, and when it saved; the organizer is in neither. */
|
|
initialAttendees: EditorAttendee[];
|
|
attendees: EditorAttendee[];
|
|
when: EventWhen;
|
|
/** The user changed the recurrence rule: a series then happens on other days. */
|
|
ruleChanged?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Whether the editor's dates change when `event` happens: it turns all-day
|
|
* or timed, or its start or end moves - for an all-day event its first or
|
|
* last day. The form's dates and the event's are display dates in the
|
|
* user's zone, compared to the minute the form shows: saving the event in
|
|
* the user's zone instead of its own is no change. Pure, for testing.
|
|
*/
|
|
export function changesWhen(event: CalendarEvent, next: EventWhen): boolean {
|
|
if (!!event.showWithoutTime !== next.allDay) return true;
|
|
if (next.allDay) {
|
|
return !isSameDay(getEventStartDate(event), next.start)
|
|
|| !isSameDay(getEventDisplayEndDate(event), next.end);
|
|
}
|
|
const minute = (date: Date) => Math.floor(date.getTime() / 60_000);
|
|
return minute(getEventStartDate(event)) !== minute(next.start)
|
|
|| minute(getEventEndDate(event)) !== minute(next.end);
|
|
}
|
|
|
|
const WHEN_KEYS = ['start', 'duration', 'timeZone', 'showWithoutTime'] as const;
|
|
type SeriesWhen = Partial<Pick<CalendarEvent, typeof WHEN_KEYS[number]>>;
|
|
|
|
/** A display date's wall clock to the minute (the form shows no seconds), as milliseconds. */
|
|
function wallClockMinutesMs(date: Date): number {
|
|
return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes());
|
|
}
|
|
|
|
/** A LocalDateTime ("YYYY-MM-DDTHH:mm:ss") as its wall clock in milliseconds, read as UTC; null when it does not parse. */
|
|
function wallClockMs(value: string): number | null {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
|
|
if (!match) return null;
|
|
const [year, month, day, hour, minute, second] = match.slice(1).map((part) => Number(part ?? 0));
|
|
const wall = new Date(0);
|
|
wall.setUTCFullYear(year, month - 1, day);
|
|
wall.setUTCHours(hour, minute, second, 0);
|
|
return wall.getTime();
|
|
}
|
|
|
|
/** A LocalDateTime ("YYYY-MM-DDTHH:mm:ss") moved by `ms` on its own wall clock. */
|
|
function shiftLocalDateTime(value: string, ms: number): string {
|
|
const wall = wallClockMs(value);
|
|
return wall === null ? value : new Date(wall + ms).toISOString().slice(0, 19);
|
|
}
|
|
|
|
/** `value` moved on the wall clock by as much as `from` moved to `to`. */
|
|
function movedLike(value: string, from: string, to: string): string {
|
|
const [before, after] = [wallClockMs(from), wallClockMs(to)];
|
|
return before === null || after === null ? value : shiftLocalDateTime(value, after - before);
|
|
}
|
|
|
|
type OverrideMap = NonNullable<CalendarEvent['recurrenceOverrides']>;
|
|
type OverrideEntry = Record<string, unknown>;
|
|
|
|
/** Where a series' occurrences are: what the keys of its overrides, their recurrence ids, are. */
|
|
interface SeriesFrame {
|
|
start: string;
|
|
timeZone?: string | null;
|
|
showWithoutTime: boolean;
|
|
recurrenceRules: CalendarEvent['recurrenceRules'];
|
|
}
|
|
|
|
/**
|
|
* The key an override of the series `from` keeps in the series `to`, or
|
|
* null when `to` has no such occurrence any more. It moves with its
|
|
* occurrence, by as much as `anchor` - the series' start, or where a new
|
|
* series takes over - moved to `to.start` (to the same day at the new time
|
|
* when the series turns all-day or timed). A rule that pins the occurrence
|
|
* where it was - a weekday rule the start moved away from - keeps the key,
|
|
* and an occurrence the new rule does not give is gone. A date the override
|
|
* adds to the series, which no rule gives, moves along.
|
|
*/
|
|
function overrideKeyIn(key: string, from: SeriesFrame, to: SeriesFrame, anchor = from.start): string | null {
|
|
const shifted = movedLike(key, anchor, to.start);
|
|
const moved = to.showWithoutTime ? `${shifted.slice(0, 10)}T00:00:00` : shifted;
|
|
if (!seriesOccursAt(from.start, from.recurrenceRules, key)) return moved;
|
|
if (seriesOccursAt(to.start, to.recurrenceRules, moved)) return moved;
|
|
if (moved !== key && seriesOccursAt(to.start, to.recurrenceRules, key)) return key;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* `entry` with its attendees asked again (participantsAskedAgain): a whole
|
|
* participants map (0.16) with their answers back at needs-action, a
|
|
* pointer at one answer (1.0 returns an override as a PatchObject) left out,
|
|
* so the series' answer - asked again - counts.
|
|
*/
|
|
function withAnswersAskedAgain(entry: OverrideEntry, series: CalendarEvent): OverrideEntry {
|
|
const organizer = { organizerCalendarAddress: series.organizerCalendarAddress, replyTo: series.replyTo };
|
|
const result: OverrideEntry = {};
|
|
for (const [key, value] of Object.entries(entry)) {
|
|
if (/^participants\/[^/]+\/participationStatus$/.test(key)) continue;
|
|
const whole = key === 'participants' || /^participants\/[^/]+$/.test(key);
|
|
if (whole && value && typeof value === 'object') {
|
|
const participants = key === 'participants'
|
|
? value as CalendarEvent['participants']
|
|
: { p: value } as unknown as CalendarEvent['participants'];
|
|
const asked = participantsAskedAgain({ ...organizer, participants });
|
|
result[key] = asked ? (key === 'participants' ? asked : asked.p) : value;
|
|
continue;
|
|
}
|
|
result[key] = value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* `entry` with the participants of a new series, `participants`: pointers
|
|
* into the old ones (1.0 returns an override as a PatchObject) dropped, so
|
|
* the series' count; a whole map (0.16) replaced by the series', or dropped
|
|
* with them. 0.16 keeps an override as an event of its own - without a map
|
|
* it has no attendees, and its occurrence goes to nobody (live on 0.16.23).
|
|
*/
|
|
function withParticipantsOf(entry: OverrideEntry, participants: CalendarEvent['participants'] | undefined): OverrideEntry {
|
|
const result = Object.fromEntries(Object.entries(entry).filter(([key]) => !key.startsWith('participants/')));
|
|
if (!('participants' in result)) return result;
|
|
if (participants && Object.keys(participants).length > 0) result.participants = participants;
|
|
else delete result.participants;
|
|
return result;
|
|
}
|
|
|
|
/** An override that changes nothing about an occurrence the series has (a PatchObject without a key). */
|
|
function isEmptyOverride(entry: OverrideEntry): boolean {
|
|
return Object.keys(entry).length === 0;
|
|
}
|
|
|
|
/**
|
|
* When the series happens after the editor moved one of its occurrences:
|
|
* its own start moved by as much as the occurrence was, on its own date and
|
|
* in its own zone; the length only when the editor changed it. Nothing when
|
|
* the occurrence did not move.
|
|
*/
|
|
function seriesWhen(
|
|
occurrence: CalendarEvent,
|
|
master: CalendarEvent,
|
|
updates: Partial<CalendarEvent>,
|
|
when: EventWhen,
|
|
): SeriesWhen {
|
|
if (!changesWhen(occurrence, when)) return {};
|
|
const result: SeriesWhen = {};
|
|
const allDayChanged = !!master.showWithoutTime !== when.allDay;
|
|
const from = getEventStartDate(occurrence);
|
|
|
|
if (when.allDay) {
|
|
// The series' first day as the user sees it, moved by the days the
|
|
// occurrence moved.
|
|
const first = !master.showWithoutTime && master.timeZone
|
|
? moveLocalDateTime(master.start, master.timeZone, getEffectiveTimeZone())
|
|
: master.start;
|
|
const days = differenceInCalendarDays(when.start, from);
|
|
if (days !== 0 || allDayChanged) {
|
|
result.start = `${shiftLocalDateTime(`${first.slice(0, 10)}T00:00:00`, days * 86_400_000).slice(0, 10)}T00:00:00`;
|
|
}
|
|
} else {
|
|
const delta = wallClockMinutesMs(when.start) - wallClockMinutesMs(from);
|
|
if (delta !== 0 || allDayChanged) result.start = shiftLocalDateTime(master.start, delta);
|
|
}
|
|
|
|
const seconds = parseDurationSeconds(occurrence.duration ?? master.duration);
|
|
if (allDayChanged || parseDurationSeconds(updates.duration) !== seconds) {
|
|
result.duration = updates.duration;
|
|
}
|
|
if (allDayChanged) {
|
|
result.showWithoutTime = when.allDay;
|
|
result.timeZone = updates.timeZone ?? null;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** An event's attendees as the editor lists them: each address once, without the organizer. */
|
|
function attendeesOf(event: CalendarEvent): EditorAttendee[] {
|
|
return getParticipantList(event)
|
|
.filter((p) => !p.isOrganizer && p.email.trim())
|
|
.map((p) => ({ name: p.name, email: p.email.trim() }));
|
|
}
|
|
|
|
/**
|
|
* The series' participants after the editor changed one occurrence's: the
|
|
* series' own, with the attendees the user added in the editor or removed
|
|
* from it. Nothing when neither happened and the series does not move -
|
|
* the series keeps its participants and their answers as they are - and
|
|
* nothing when someone else organizes the series.
|
|
*/
|
|
function seriesParticipants(
|
|
master: CalendarEvent,
|
|
editor: EventEditorState,
|
|
context: ParticipantSaveContext,
|
|
): Partial<Pick<CalendarEvent, 'participants' | 'organizerCalendarAddress' | 'replyTo'>> {
|
|
const userEmails = [context.userEmail ?? '', ...(context.userEmails ?? [])].filter(Boolean);
|
|
if (!userOrganizes(master, userEmails)) return {};
|
|
const key = (email: string) => email.trim().toLowerCase();
|
|
const before = new Set(editor.initialAttendees.map((a) => key(a.email)));
|
|
const after = new Set(editor.attendees.map((a) => key(a.email)));
|
|
const added = editor.attendees.filter((a) => !before.has(key(a.email)));
|
|
const removed = new Set([...before].filter((address) => !after.has(address)));
|
|
if (added.length === 0 && removed.size === 0 && !context.rescheduled) return {};
|
|
const attendees = [...attendeesOf(master).filter((a) => !removed.has(key(a.email))), ...added];
|
|
return participantFieldsToSave(master, attendees, context);
|
|
}
|
|
|
|
/**
|
|
* The series' recurrence overrides once `series` - its change - applies,
|
|
* or undefined when they stay as they are.
|
|
*
|
|
* An override is keyed by its occurrence's recurrence id, where the rule
|
|
* puts the occurrence. A series that moved kept them under the old ids: an
|
|
* excluded occurrence came back, a changed one showed twice (as changed at
|
|
* the old time, and as the series has it), in the attendees' copies too
|
|
* (live on 1.0 and 0.16.23). Now each override goes with its occurrence
|
|
* (overrideKeyIn), and an override of an occurrence the series no longer
|
|
* has is dropped. An occurrence moved on its own keeps its time, as the
|
|
* user placed it - but the one the editor was opened on takes the move the
|
|
* user made there - while an override that only holds the time the
|
|
* occurrence had moves with the series; so does the length. A series
|
|
* turned all-day or timed takes every occurrence along. When the series'
|
|
* attendees are asked again, so they are for each occurrence that moves.
|
|
*/
|
|
function seriesOverrides(
|
|
occurrence: CalendarEvent,
|
|
master: CalendarEvent,
|
|
series: Partial<CalendarEvent>,
|
|
options: { ruleChanged: boolean; askAgain: boolean },
|
|
): OverrideMap | null | undefined {
|
|
const overrides = master.recurrenceOverrides;
|
|
if (!overrides || Object.keys(overrides).length === 0) return undefined;
|
|
const resized = series.duration !== undefined;
|
|
if (series.start === undefined && series.showWithoutTime === undefined && !resized && !options.ruleChanged) return undefined;
|
|
|
|
const from: SeriesFrame = { start: master.start, showWithoutTime: !!master.showWithoutTime, recurrenceRules: master.recurrenceRules };
|
|
const to: SeriesFrame = {
|
|
start: series.start ?? master.start,
|
|
showWithoutTime: series.showWithoutTime ?? !!master.showWithoutTime,
|
|
recurrenceRules: options.ruleChanged ? series.recurrenceRules ?? null : master.recurrenceRules,
|
|
};
|
|
const turned = from.showWithoutTime !== to.showWithoutTime;
|
|
const opened = resolveOverrideKey({ start: occurrence.start, recurrenceId: occurrence.recurrenceId ?? master.start }, overrides);
|
|
|
|
const result: OverrideMap = {};
|
|
for (const [key, override] of Object.entries(overrides)) {
|
|
const target = overrideKeyIn(key, from, to);
|
|
if (!target) continue;
|
|
const { updated: _updated, ...rest } = override as OverrideEntry;
|
|
let entry: OverrideEntry = rest;
|
|
if (!entry.excluded) {
|
|
let follows = true;
|
|
if (turned) {
|
|
for (const timing of WHEN_KEYS) delete entry[timing];
|
|
} else {
|
|
if (typeof entry.start === 'string') {
|
|
const pinned = wallClockMs(entry.start) === wallClockMs(key)
|
|
&& (entry.timeZone === undefined || entry.timeZone === master.timeZone);
|
|
follows = pinned || key === opened;
|
|
if (follows) entry.start = movedLike(entry.start, from.start, to.start);
|
|
}
|
|
if (resized && typeof entry.duration === 'string'
|
|
&& (key === opened || parseDurationSeconds(entry.duration) === parseDurationSeconds(master.duration))) {
|
|
entry.duration = series.duration;
|
|
}
|
|
}
|
|
if (options.askAgain && follows) entry = withAnswersAskedAgain(entry, master);
|
|
if (isEmptyOverride(entry) && seriesOccursAt(to.start, to.recurrenceRules, target)) continue;
|
|
}
|
|
result[target] = entry as OverrideMap[string];
|
|
}
|
|
return Object.keys(result).length > 0 ? result : null;
|
|
}
|
|
|
|
/**
|
|
* The editor's `updates`, made on `occurrence`, as a change to its whole
|
|
* series `master`.
|
|
*
|
|
* The editor shows one occurrence, so the dates and participants it writes
|
|
* are that occurrence's. Written to the series as they are, the series
|
|
* started on the occurrence's day - the occurrences before it gone, a
|
|
* counted series running longer - and every occurrence got that one
|
|
* occurrence's attendees and answers. The series keeps its own instead:
|
|
* - when it happens, unless the occurrence moved; then the series moves by
|
|
* as much from its own start (seriesWhen);
|
|
* - its participants and their answers, with the attendees the user added
|
|
* or removed; when the series moves, the user as its organizer asks the
|
|
* attendees again (participantFieldsToSave);
|
|
* - its overrides, each with its occurrence (seriesOverrides).
|
|
*/
|
|
export function seriesUpdatesFromOccurrence(
|
|
occurrence: CalendarEvent,
|
|
master: CalendarEvent,
|
|
updates: Partial<CalendarEvent>,
|
|
editor: EventEditorState,
|
|
context: Omit<ParticipantSaveContext, 'rescheduled'>,
|
|
): Partial<CalendarEvent> {
|
|
const series: Partial<CalendarEvent> = { ...updates };
|
|
for (const key of [...WHEN_KEYS, 'recurrenceId', 'participants', 'organizerCalendarAddress', 'replyTo'] as const) {
|
|
delete series[key];
|
|
}
|
|
const when = seriesWhen(occurrence, master, updates, editor.when);
|
|
Object.assign(series, when);
|
|
const rescheduled = Object.keys(when).length > 0 || !!editor.ruleChanged;
|
|
const participants = seriesParticipants(master, editor, { ...context, rescheduled });
|
|
Object.assign(series, participants);
|
|
const overrides = seriesOverrides(occurrence, master, series, {
|
|
ruleChanged: !!editor.ruleChanged,
|
|
askAgain: rescheduled && !!participants.participants,
|
|
});
|
|
if (overrides !== undefined) series.recurrenceOverrides = overrides;
|
|
return series;
|
|
}
|
|
|
|
/** Options of the calendar store's updateEvent. */
|
|
export interface EventUpdateOptions {
|
|
/**
|
|
* The user organizes the event. A change to one occurrence that gives it
|
|
* its own override then reaches the attendees who have answered too
|
|
* (see the calendar store's updateEvent).
|
|
*/
|
|
asOrganizer?: boolean;
|
|
}
|
|
|
|
/** What saving an occurrence edit works with: the calendar store's actions and the app's series lookup. */
|
|
export interface OccurrenceEditActions {
|
|
client: IJMAPClient;
|
|
updateEvent: (
|
|
client: IJMAPClient,
|
|
id: string,
|
|
updates: Partial<CalendarEvent>,
|
|
sendSchedulingMessages?: boolean,
|
|
options?: EventUpdateOptions,
|
|
) => Promise<void>;
|
|
createEvent: (client: IJMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
|
|
/** The base event of a series, from one of its occurrences. */
|
|
findMasterEvent: (occurrence: CalendarEvent) => Promise<CalendarEvent | null>;
|
|
}
|
|
|
|
export interface OccurrenceEdit {
|
|
/** The occurrence the editor was opened on. */
|
|
event: CalendarEvent;
|
|
/** What the editor wrote for it. */
|
|
updates: Partial<CalendarEvent>;
|
|
/** What the editor had. Without it the updates go to the series as they are. */
|
|
editor?: EventEditorState;
|
|
sendScheduling?: boolean;
|
|
/** The user's calendar addresses; the first organizes new invitations. */
|
|
userEmails: string[];
|
|
/** A name for an address the event data carries none for. */
|
|
resolveName?: (email: string) => string | undefined;
|
|
}
|
|
|
|
/** A series itself, not one of its occurrences: it has rules and no recurrence id. */
|
|
function isSeries(event: CalendarEvent): boolean {
|
|
return (event.recurrenceRules?.length ?? 0) > 0 && !event.recurrenceId;
|
|
}
|
|
|
|
/**
|
|
* `event` as an occurrence of `master`, to override or exclude on it: itself
|
|
* when it is one, the series' first occurrence when it is the series itself.
|
|
* The calendar shows a series it has not expanded - one just created, one
|
|
* opened from a link - as the series, once, at its first occurrence; a
|
|
* change to "this occurrence" of it went to the series and rewrote every
|
|
* occurrence, and deleting it deleted them all.
|
|
*/
|
|
function asOccurrenceOf(event: CalendarEvent, master: CalendarEvent): CalendarEvent {
|
|
const occurrence = event.recurrenceId
|
|
? event
|
|
: { ...event, recurrenceId: master.start, recurrenceIdTimeZone: master.timeZone ?? null };
|
|
return overrideContextOf(occurrence, master);
|
|
}
|
|
|
|
/** Whether `event` is `master`'s first occurrence, or the series itself standing for it. */
|
|
function isFirstOccurrence(event: CalendarEvent, master: CalendarEvent): boolean {
|
|
if (!event.recurrenceId) return true;
|
|
return parseISO(event.recurrenceId).getTime() === parseISO(master.start).getTime();
|
|
}
|
|
|
|
/** The recurrence id `occurrence` has in `master`, the key of its override there. */
|
|
function splitKeyOf(occurrence: CalendarEvent, master: CalendarEvent): string {
|
|
return resolveOverrideKey(occurrence, master.recurrenceOverrides) ?? occurrence.recurrenceId ?? occurrence.start;
|
|
}
|
|
|
|
/**
|
|
* End the series `master` just before `occurrence`. With `sendScheduling`,
|
|
* when the user organizes the series, the attendees are told: Stalwart
|
|
* sends them the shortened series, and their copies end there too (they
|
|
* kept every occurrence before). The overrides from `occurrence` on go as
|
|
* well: a rule that ends does not end them, and each kept its occurrence -
|
|
* a changed one showed on in the old series, next to the new one (live on
|
|
* 1.0 and 0.16.23). Resolves to what puts the series back, for a rollback -
|
|
* its rules as they were and those overrides - whether the attendees were
|
|
* told, and the overrides, for the new series.
|
|
*/
|
|
async function truncateSeries(
|
|
master: CalendarEvent,
|
|
occurrence: CalendarEvent,
|
|
actions: Pick<OccurrenceEditActions, 'client' | 'updateEvent'>,
|
|
scheduling: { sendScheduling?: boolean; userEmails?: string[] } = {},
|
|
): Promise<{ restore: Partial<CalendarEvent>; announced: true | undefined; removed: OverrideMap }> {
|
|
const originalRules = master.recurrenceRules
|
|
? JSON.parse(JSON.stringify(master.recurrenceRules))
|
|
: null;
|
|
const splitKey = splitKeyOf(occurrence, master);
|
|
const untilDate = new Date(splitKey);
|
|
untilDate.setSeconds(untilDate.getSeconds() - 1);
|
|
const until = format(untilDate, "yyyy-MM-dd'T'HH:mm:ss");
|
|
const truncatedRules = (master.recurrenceRules || []).map(rule => ({
|
|
...rule,
|
|
until,
|
|
count: null,
|
|
}));
|
|
const patch: Record<string, unknown> = { recurrenceRules: truncatedRules };
|
|
const restore: Record<string, unknown> = { recurrenceRules: originalRules };
|
|
const removed: OverrideMap = {};
|
|
const split = wallClockMs(splitKey);
|
|
for (const [key, override] of Object.entries(master.recurrenceOverrides ?? {})) {
|
|
const at = wallClockMs(key);
|
|
if (at === null || split === null || at < split) continue;
|
|
removed[key] = override;
|
|
patch[`recurrenceOverrides/${key}`] = null;
|
|
restore[`recurrenceOverrides/${key}`] = override;
|
|
}
|
|
const announced = scheduling.sendScheduling && userOrganizes(master, scheduling.userEmails ?? []) ? true : undefined;
|
|
await actions.updateEvent(actions.client, master.id, patch as Partial<CalendarEvent>, announced);
|
|
return { restore: restore as Partial<CalendarEvent>, announced, removed };
|
|
}
|
|
|
|
/**
|
|
* The rules of the series "this and the following occurrences" becomes:
|
|
* the old ones, each with what is left of its COUNT - the occurrences
|
|
* before `splitKey` stay with the old series. Copied as they were, the new
|
|
* series ran the whole count again from the split. An UNTIL stays.
|
|
*/
|
|
function remainingRules(
|
|
rules: CalendarEvent['recurrenceRules'],
|
|
seriesStart: string,
|
|
splitKey: string,
|
|
): CalendarEvent['recurrenceRules'] {
|
|
if (!rules) return rules;
|
|
return rules.map((rule) => (rule.count
|
|
? { ...rule, count: Math.max(1, rule.count - occurrencesBefore(seriesStart, rule, splitKey)) }
|
|
: rule));
|
|
}
|
|
|
|
/**
|
|
* The overrides of the new series "this and the following occurrences"
|
|
* becomes: those the old series had after the split (truncateSeries), each
|
|
* with its occurrence (overrideKeyIn). They keep what the organizer changed
|
|
* - an exclusion, a title, a time of their own - but take the new series'
|
|
* participants (withParticipantsOf): it asks every attendee again, for each
|
|
* occurrence, and an attendee's answer to one occurrence of the old series
|
|
* (Stalwart keeps it as an override holding that attendee) would leave the
|
|
* others out of it. The occurrence the editor was opened on starts the new
|
|
* series, which is what the editor wrote for it: its override stays behind.
|
|
*/
|
|
function followingOverrides(
|
|
overrides: OverrideMap | undefined,
|
|
splitKey: string,
|
|
from: SeriesFrame,
|
|
to: SeriesFrame & { participants?: CalendarEvent['participants'] },
|
|
): OverrideMap | undefined {
|
|
if (!overrides || Object.keys(overrides).length === 0) return undefined;
|
|
const turned = from.showWithoutTime !== to.showWithoutTime;
|
|
const result: OverrideMap = {};
|
|
for (const [key, override] of Object.entries(overrides)) {
|
|
if (key === splitKey) continue;
|
|
const target = overrideKeyIn(key, from, to, splitKey);
|
|
if (!target) continue;
|
|
const { updated: _updated, ...rest } = override as OverrideEntry;
|
|
const entry = withParticipantsOf(rest, to.participants);
|
|
if (!entry.excluded) {
|
|
if (turned) {
|
|
for (const timing of WHEN_KEYS) delete entry[timing];
|
|
} else if (typeof entry.start === 'string') {
|
|
if (wallClockMs(entry.start) === wallClockMs(key)) {
|
|
entry.start = target;
|
|
} else if (entry.timeZone === undefined && (from.timeZone ?? null) !== (to.timeZone ?? null)) {
|
|
// A time of its own, on the old series' clock.
|
|
entry.timeZone = from.timeZone ?? null;
|
|
}
|
|
}
|
|
if (isEmptyOverride(entry) && seriesOccursAt(to.start, to.recurrenceRules, target)) continue;
|
|
}
|
|
result[target] = entry as OverrideMap[string];
|
|
}
|
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
}
|
|
|
|
/**
|
|
* The new series "this and the following occurrences" becomes: the old
|
|
* series' details with the editor's changes, from `occurrence` on, with
|
|
* what is left of its COUNT and the overrides it takes over (`overrides`,
|
|
* from truncateSeries). It is a new event with a UID of its own, so every
|
|
* attendee is asked (participantsAskedAgain): Stalwart invites to a new
|
|
* event only attendees who have not answered, and with the answers the old
|
|
* series had, those who had answered never got the new one.
|
|
*/
|
|
export function followingSeriesData(
|
|
occurrence: CalendarEvent,
|
|
master: CalendarEvent,
|
|
updates: Partial<CalendarEvent>,
|
|
originalRules: CalendarEvent['recurrenceRules'],
|
|
options: { editor?: EventEditorState; overrides?: OverrideMap } = {},
|
|
): Partial<CalendarEvent> {
|
|
const splitKey = splitKeyOf(occurrence, master);
|
|
// A rule the user changed in the editor is the new series' own, its COUNT
|
|
// included; one the user left alone continues the old series.
|
|
const ruleChanged = options.editor
|
|
? !!options.editor.ruleChanged
|
|
: updates.recurrenceRules !== undefined && !sameRecurrenceRules(updates.recurrenceRules, originalRules);
|
|
const { recurrenceRules: editorRules, ...changes } = updates;
|
|
const data: Partial<CalendarEvent> = {
|
|
title: master.title,
|
|
description: master.description,
|
|
duration: master.duration,
|
|
timeZone: master.timeZone,
|
|
calendarIds: { ...master.calendarIds },
|
|
status: master.status,
|
|
freeBusyStatus: master.freeBusyStatus,
|
|
privacy: master.privacy,
|
|
showWithoutTime: master.showWithoutTime,
|
|
...changes,
|
|
recurrenceRules: ruleChanged && editorRules !== undefined ? editorRules : remainingRules(originalRules, master.start, splitKey),
|
|
start: updates.start || occurrence.recurrenceId || occurrence.start,
|
|
};
|
|
delete data.id;
|
|
delete data.uid;
|
|
delete data.recurrenceId;
|
|
if (data.participants) {
|
|
data.participants = participantsAskedAgain({
|
|
participants: data.participants,
|
|
organizerCalendarAddress: data.organizerCalendarAddress ?? master.organizerCalendarAddress,
|
|
}) ?? data.participants;
|
|
}
|
|
const overrides = followingOverrides(
|
|
options.overrides, splitKey,
|
|
{ start: master.start, timeZone: master.timeZone, showWithoutTime: !!master.showWithoutTime, recurrenceRules: originalRules },
|
|
{
|
|
start: data.start!, timeZone: data.timeZone, showWithoutTime: !!data.showWithoutTime,
|
|
recurrenceRules: data.recurrenceRules ?? null, participants: data.participants,
|
|
},
|
|
);
|
|
if (overrides) data.recurrenceOverrides = overrides;
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Save the editor's change to one occurrence for `scope`. Resolves to false
|
|
* when the series the occurrence belongs to cannot be found.
|
|
*/
|
|
export async function saveOccurrenceEdit(
|
|
scope: RecurrenceEditScope,
|
|
edit: OccurrenceEdit,
|
|
actions: OccurrenceEditActions,
|
|
): Promise<boolean> {
|
|
const { client, updateEvent, createEvent, findMasterEvent } = actions;
|
|
const { event, updates, sendScheduling } = edit;
|
|
switch (scope) {
|
|
case 'this': {
|
|
if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) {
|
|
// The store writes one occurrence through its own (synthetic) id, or
|
|
// as a recurrence override on the base event it was expanded from.
|
|
const asOrganizer = userOrganizes({
|
|
...event,
|
|
participants: updates.participants === undefined ? event.participants : updates.participants,
|
|
}, edit.userEmails);
|
|
await updateEvent(client, event.id, updates, sendScheduling, { asOrganizer });
|
|
return true;
|
|
}
|
|
// An override outside an expanded series, or the series itself standing
|
|
// for its first occurrence: override that occurrence on the series,
|
|
// never write it to the series itself.
|
|
const master = await findMasterEvent(event);
|
|
if (master) {
|
|
const occurrence = asOccurrenceOf(event, master);
|
|
const overridePatch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true }));
|
|
if (!overridePatch) return false;
|
|
await updateEvent(client, master.id, overridePatch, sendScheduling);
|
|
return true;
|
|
}
|
|
// One occurrence the server keeps as an event of its own (an
|
|
// invitation to that occurrence alone) is itself what changes.
|
|
if (isSeries(event)) return false;
|
|
await updateEvent(client, event.id, updates, sendScheduling);
|
|
return true;
|
|
}
|
|
case 'this_and_future': {
|
|
const master = await findMasterEvent(event);
|
|
if (!master) return false;
|
|
// From the first occurrence on is the whole series; ended before its
|
|
// first occurrence, the series would be left with none.
|
|
if (isFirstOccurrence(event, master)) return saveOccurrenceEdit('all', edit, actions);
|
|
const { restore, announced, removed } = await truncateSeries(master, event, actions, { sendScheduling, userEmails: edit.userEmails });
|
|
const newEventData = followingSeriesData(event, master, updates, restore.recurrenceRules ?? null, {
|
|
editor: edit.editor,
|
|
overrides: removed,
|
|
});
|
|
try {
|
|
await createEvent(client, newEventData, sendScheduling);
|
|
} catch (createError) {
|
|
debug.error('Failed to create new series, rolling back master truncation:', createError);
|
|
try {
|
|
await updateEvent(client, master.id, restore, announced);
|
|
} catch (rollbackError) {
|
|
debug.error('Rollback of master event also failed:', rollbackError);
|
|
}
|
|
throw createError;
|
|
}
|
|
return true;
|
|
}
|
|
case 'all': {
|
|
const master = await findMasterEvent(event);
|
|
if (!master) return false;
|
|
let seriesUpdates: Partial<CalendarEvent>;
|
|
if (edit.editor) {
|
|
seriesUpdates = seriesUpdatesFromOccurrence(event, master, updates, edit.editor, {
|
|
userEmail: edit.userEmails[0],
|
|
userEmails: edit.userEmails,
|
|
resolveName: edit.resolveName,
|
|
});
|
|
} else {
|
|
seriesUpdates = { ...updates };
|
|
delete seriesUpdates.recurrenceId;
|
|
}
|
|
await updateEvent(client, master.id, seriesUpdates, sendScheduling);
|
|
return true;
|
|
}
|
|
default: {
|
|
const exhaustive: never = scope;
|
|
throw new Error(`Unhandled scope: ${exhaustive}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** What deleting part of a series works with: the calendar store's actions and the app's series lookup. */
|
|
export interface OccurrenceDeleteActions extends Pick<OccurrenceEditActions, 'client' | 'updateEvent' | 'findMasterEvent'> {
|
|
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Delete the part of a series `scope` names, from `event`, the occurrence
|
|
* the user picked it on. Resolves to false when the series cannot be found.
|
|
*/
|
|
export async function deleteOccurrence(
|
|
scope: RecurrenceEditScope,
|
|
deletion: { event: CalendarEvent; sendScheduling?: boolean; userEmails: string[] },
|
|
actions: OccurrenceDeleteActions,
|
|
): Promise<boolean> {
|
|
const { client, updateEvent, deleteEvent, findMasterEvent } = actions;
|
|
const { event, sendScheduling } = deletion;
|
|
switch (scope) {
|
|
case 'this': {
|
|
if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) {
|
|
// The store destroys a server occurrence, or excludes one the
|
|
// browser expanded on its base event.
|
|
await deleteEvent(client, event.id, sendScheduling);
|
|
return true;
|
|
}
|
|
// An override outside an expanded series, or the series itself standing
|
|
// for its first occurrence: exclude that occurrence on the series,
|
|
// never delete the series.
|
|
const master = await findMasterEvent(event);
|
|
if (master) {
|
|
const excludePatch = buildFallbackExcludePatch(asOccurrenceOf(event, master));
|
|
if (!excludePatch) return false;
|
|
await updateEvent(client, master.id, excludePatch, sendScheduling);
|
|
return true;
|
|
}
|
|
if (isSeries(event)) return false;
|
|
await deleteEvent(client, event.id, sendScheduling);
|
|
return true;
|
|
}
|
|
case 'this_and_future': {
|
|
const master = await findMasterEvent(event);
|
|
if (!master) return false;
|
|
if (isFirstOccurrence(event, master)) {
|
|
await deleteEvent(client, master.id, sendScheduling);
|
|
return true;
|
|
}
|
|
await truncateSeries(master, event, actions, { sendScheduling, userEmails: deletion.userEmails });
|
|
return true;
|
|
}
|
|
case 'all': {
|
|
const master = await findMasterEvent(event);
|
|
if (!master) return false;
|
|
await deleteEvent(client, master.id, sendScheduling);
|
|
return true;
|
|
}
|
|
default: {
|
|
const exhaustive: never = scope;
|
|
throw new Error(`Unhandled scope: ${exhaustive}`);
|
|
}
|
|
}
|
|
}
|