Files
webmail/lib/recurrence-scope-edit.ts

1155 lines
54 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, 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,
hasAnsweredAttendees,
participantFieldsToSave,
participantsAskedAgain,
userOrganizes,
type ParticipantSaveContext,
} from '@/lib/calendar-participants';
import {
buildFallbackExcludePatch,
buildFallbackOverridePatch,
buildOccurrencePatch,
createsOverride,
isBrowserExpandedOccurrence,
isServerRecurrenceInstance,
overrideContextOf,
resolveOverrideKey,
stableJson,
withNewOverrideDetails,
} from '@/lib/recurrence-instances';
import { occurrencesBefore, seriesOccursAt } from '@/lib/recurrence-expansion';
import { sameRecurrenceRules } from '@/lib/recurrence-rule';
import {
fromDisplayDate,
getEffectiveTimeZone,
getTimeZoneOffsetMs,
isValidTimeZone,
moveLocalDateTime,
toLocalDateTime,
} 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'];
}
/**
* Whether `rules` name the days of their occurrences - BYDAY, BYMONTHDAY,
* BYYEARDAY; other clients write BYDAY even for a single weekday. A start
* moved by days does not take those occurrences along.
*/
function namesDays(rules: CalendarEvent['recurrenceRules']): boolean {
return (rules ?? []).some((rule) => !!(rule.byDay?.length || rule.byMonthDay?.length || rule.byYearDay?.length));
}
/**
* 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.
*
* A rule that names the days keeps every occurrence but the anchor on its
* day, at the series' new time, or has it no more: moved by a day, the
* Monday of a series on Mondays and Tuesdays is still that Monday. Moved
* along, its override landed on the Tuesday's and took its place: the
* cancelled Tuesday came back with the Monday's title, and the Monday lost
* its change (live on 1.0 and 0.16.23).
*/
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 (namesDays(to.recurrenceRules) && wallClockMs(key) !== wallClockMs(anchor)) {
const onItsDay = `${key.slice(0, 10)}${moved.slice(10)}`;
return seriesOccursAt(to.start, to.recurrenceRules, onItsDay) ? onItsDay : null;
}
if (seriesOccursAt(to.start, to.recurrenceRules, moved)) return moved;
if (moved !== key && seriesOccursAt(to.start, to.recurrenceRules, key)) return key;
return null;
}
/**
* The key each of `overrides` keeps in the series `to` (overrideKeyIn), by
* its key in `from`, never two on one occurrence. Two still meet when the
* anchor's override moves onto the day of another - a series of Mondays and
* Tuesdays that starts a day later - or when an hourly series turns all-day.
* The override that stays where it is, else the one that stays on its day,
* else the first keeps the occurrence; the others are left out, and
* logged, rather than written over it.
*/
function overrideKeysIn(overrides: OverrideMap, from: SeriesFrame, to: SeriesFrame, anchor?: string): Map<string, string> {
const claims = new Map<string, string[]>();
for (const key of Object.keys(overrides)) {
const target = overrideKeyIn(key, from, to, anchor);
if (target) claims.set(target, [...(claims.get(target) ?? []), key]);
}
const keys = new Map<string, string>();
for (const [target, claimed] of claims) {
const kept = claimed.find((key) => key === target)
?? claimed.find((key) => key.slice(0, 10) === target.slice(0, 10))
?? claimed[0];
keys.set(kept, target);
if (claimed.length > 1) {
debug.warn('calendar', 'Recurrence overrides that meet on one occurrence; keeping one', target, kept, claimed);
}
}
return keys;
}
/**
* `entry` with its attendees asked again (participantsAskedAgain): a whole
* participants map (0.16) with their answers back at needs-action - but the
* user's own, under `userEmails` - a pointer at one answer (1.0 returns an
* override as a PatchObject) left out, so the series' answer counts.
*/
function withAnswersAskedAgain(entry: OverrideEntry, series: CalendarEvent, userEmails: string[]): 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 }, userEmails);
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;
}
/** `date`, a display date in the user's zone, as the wall clock of `timeZone` to the minute, in milliseconds. */
function zonedMinutesMs(date: Date, timeZone: string): number {
const wall = wallClockMs(toLocalDateTime(fromDisplayDate(date).toISOString(), timeZone)) ?? 0;
return Math.floor(wall / 60_000) * 60_000;
}
/**
* How far the editor moved an occurrence from `from` to `to` (display dates)
* on the series' own clock: its zone's wall clock when it has one. Measured
* on the user's clock, a move across a week in which only one of the two
* zones changed to or from daylight saving time moved the series by an hour
* more or less than the occurrence: from Saturday 10:00 in New York to
* Monday 11:00 there (16:00 on both days for a user in Berlin) made it
* Mondays at 10:00.
*/
function seriesClockDelta(from: Date, to: Date, series: CalendarEvent): number {
const zone = !series.showWithoutTime && series.timeZone && isValidTimeZone(series.timeZone) ? series.timeZone : null;
return zone
? zonedMinutesMs(to, zone) - zonedMinutesMs(from, zone)
: wallClockMinutesMs(to) - wallClockMinutesMs(from);
}
/**
* 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 (seriesClockDelta); 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 = seriesClockDelta(from, when.start, master);
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;
}
/** The editor's fields a save changes on its own: when (seriesWhen), who (participantFieldsToSave), which occurrence. */
const NOT_EDITOR_FIELDS = new Set<string>([...WHEN_KEYS, 'recurrenceId', 'participants', 'organizerCalendarAddress', 'replyTo']);
/** The first location's name, or virtual location's URI, as the editor shows it. */
const firstOf = (map: Record<string, { name?: string | null; uri?: string | null }> | null | undefined, field: 'name' | 'uri') =>
(Object.values(map ?? {})[0]?.[field] ?? '').trim();
/**
* The fields of `updates` - what the editor wrote for `occurrence` - that
* the user changed there, compared the way the editor shows them. The
* editor writes every field it shows, and on an occurrence with an override
* of its own those are the occurrence's: its title, copied when it got the
* override, or a room of its own. Written to the whole series, a new room
* on such an occurrence also gave the series back that occurrence's old
* title.
*/
export function changedEditorFields(
occurrence: CalendarEvent,
updates: Partial<CalendarEvent>,
editor?: Pick<EventEditorState, 'ruleChanged'>,
): Partial<CalendarEvent> {
const ruleChanged = editor ? !!editor.ruleChanged : !sameRecurrenceRules(occurrence.recurrenceRules, updates.recurrenceRules);
const changed: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (NOT_EDITOR_FIELDS.has(key)) continue;
let differs: boolean;
switch (key) {
case 'title':
differs = (value ?? '') !== (occurrence.title ?? '');
break;
case 'description':
differs = String(value ?? '').trim() !== (occurrence.description ?? '').trim();
break;
case 'locations':
differs = firstOf(value as CalendarEvent['locations'], 'name') !== firstOf(occurrence.locations, 'name');
break;
case 'virtualLocations':
differs = firstOf(value as CalendarEvent['virtualLocations'], 'uri') !== firstOf(occurrence.virtualLocations, 'uri');
break;
case 'calendarIds':
differs = stableJson(Object.keys(value ?? {}).sort()) !== stableJson(Object.keys(occurrence.calendarIds ?? {}).sort());
break;
case 'recurrenceRules':
case 'recurrenceOverrides':
case 'excludedRecurrenceRules':
differs = ruleChanged;
break;
default:
differs = stableJson(value) !== stableJson((occurrence as unknown as Record<string, unknown>)[key]);
}
if (differs) changed[key] = value;
}
return changed as Partial<CalendarEvent>;
}
/**
* `entry`, an override of `series`, with the editor's `changes` to the
* series, where it holds a value of the field: the override of the
* occurrence the editor was opened on takes them all - they were made on it
* - any other one where it only held the series' old value (0.16 keeps a
* copy of the title, place and details in every override Bulwark writes:
* without this, a renamed series kept its old name on those occurrences). A
* value an override has of its own stays; one it does not hold comes from
* the series.
*/
function withSeriesChanges(entry: OverrideEntry, changes: Partial<CalendarEvent>, series: CalendarEvent, opened: boolean): OverrideEntry {
const result = { ...entry };
// Only what one occurrence may carry (buildOccurrencePatch): not the
// rule, the calendar or the default alerts switch.
for (const [key, value] of Object.entries(buildOccurrencePatch(changes))) {
// 1.0 returns an override as a PatchObject: a field of its own can be
// pointers into it (`locations/loc1/name`), which would keep the old
// value on the occurrence the change was made on.
const pointers = Object.keys(result).filter((name) => name.startsWith(`${key}/`));
if (opened && (key in result || pointers.length > 0)) {
for (const name of pointers) delete result[name];
result[key] = value;
} else if (!opened && key in result && stableJson(result[key]) === stableJson((series as unknown as Record<string, unknown>)[key])) {
result[key] = value;
}
}
return result;
}
/**
* Whether `entry`, the override under `key`, changes nothing about its
* occurrence in `series`: each value it holds is the series' - its start the
* occurrence's own. The server's bookkeeping in it does not count.
*/
function changesNothing(entry: OverrideEntry, key: string, series: Partial<CalendarEvent>): boolean {
return !entry.excluded && Object.entries(entry).every(([name, value]) => {
if (name === 'sequence' || name === 'updated') return true;
if (name === 'start') return typeof value === 'string' && wallClockMs(value) === wallClockMs(key);
return stableJson(value) === stableJson((series as unknown as Record<string, unknown>)[name]);
});
}
/** 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
* (overrideKeysIn), and an override of an occurrence the series no longer
* has is dropped - every one when the series no longer repeats. 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
* it; 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. What the user changed in the editor goes
* into the overrides as well (withSeriesChanges).
*/
function seriesOverrides(
occurrence: CalendarEvent,
master: CalendarEvent,
series: Partial<CalendarEvent>,
options: { ruleChanged: boolean; askAgain: boolean; changes: Partial<CalendarEvent>; userEmails: string[] },
): OverrideMap | null | undefined {
const overrides = master.recurrenceOverrides;
if (!overrides || Object.keys(overrides).length === 0) return undefined;
const resized = series.duration !== undefined;
const moves = series.start !== undefined || series.showWithoutTime !== undefined;
const changes = Object.keys(buildOccurrencePatch(options.changes)).length > 0;
if (!moves && !resized && !options.ruleChanged && !changes) 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,
};
// "Repeat: none": one event is left, with no occurrence to override. The
// override of its start stayed: 1.0 applied it to the event - excluded, the
// event was gone from every range - and 0.16.23 listed a changed one next
// to the event (live).
if (options.ruleChanged && !to.recurrenceRules?.length) return null;
const turned = from.showWithoutTime !== to.showWithoutTime;
const opened = resolveOverrideKey({ start: occurrence.start, recurrenceId: occurrence.recurrenceId ?? master.start }, overrides);
const targets = overrideKeysIn(overrides, from, to);
const result: OverrideMap = {};
for (const [key, override] of Object.entries(overrides)) {
const target = targets.get(key);
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;
// One that only held its occurrence's time holds the time the
// occurrence has now; the opened one, moved on its own, takes the
// editor's move.
if (pinned) entry.start = movedLike(entry.start, key, target);
else if (key === opened) 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, options.userEmails);
if (changes) entry = withSeriesChanges(entry, options.changes, master, key === opened);
if (seriesOccursAt(to.start, to.recurrenceRules, target) && changesNothing(entry, target, { ...master, ...series })) continue;
}
result[target] = entry as OverrideMap[string];
}
// Nothing moved and no override took a change: they stay as they are.
if (stableJson(result) === stableJson(withoutUpdated(overrides))) return undefined;
return Object.keys(result).length > 0 ? result : null;
}
/** Overrides without the server's `updated` stamps, as a rewrite sends them. */
function withoutUpdated(overrides: OverrideMap): OverrideMap {
return Object.fromEntries(Object.entries(overrides).map(([key, override]) => {
const { updated: _updated, ...rest } = override as OverrideEntry;
return [key, rest];
})) as OverrideMap;
}
/**
* 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:
* - its details, but for those the user changed (changedEditorFields);
* - 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 changes = changedEditorFields(occurrence, updates, editor);
const series: Partial<CalendarEvent> = { ...changes };
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,
changes,
userEmails: [context.userEmail ?? '', ...(context.userEmails ?? [])].filter(Boolean),
});
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;
/** The update sets `sequence` itself (withRaisedSequences); it is not stripped. */
keepSequence?: boolean;
/**
* Builds the update from the base event as the server holds it, read
* right before the write through the account the write goes to; it
* replaces the update given, which goes out when there is nothing to read.
*/
fromCurrent?: (current: CalendarEvent) => Partial<CalendarEvent>;
}
/** Changes of a series its attendees see - Stalwart announces them, and 1.0 raises the SEQUENCE for them. */
const ANNOUNCED_KEYS = [
'title', 'description', 'locations', 'virtualLocations', 'status', 'participants', 'recurrenceRules',
'excludedRecurrenceRules', 'recurrenceOverrides', ...WHEN_KEYS,
] as const;
/**
* `series`, a change to the series `master` its attendees are told of, with
* the SEQUENCE of the series and of each of its changed occurrences one up.
*
* 0.16 (0.16.6 to 0.16.23) sends the change of one occurrence with that
* occurrence's SEQUENCE raised, but does not keep the raise itself (in
* organizer.rs the list of components to raise is shadowed for such
* changes). A later change of every occurrence that does not raise the
* number again - a new title - then carried a lower SEQUENCE for that
* occurrence than the attendee's copy has, and the attendee's server
* refused the whole change as out of sequence: the mail arrived, the copy
* stayed as it was (live on 0.16.23). One up from what the organizer's
* copy holds is at least what the attendees got. 1.0 raises every
* occurrence's SEQUENCE the same way itself for a change it announces, and
* keeps a number it would have given (live: the same result with and
* without).
*
* `master` is the series as the server holds it right before the write
* (EventUpdateOptions.fromCurrent): a copy read earlier - a series opened
* from a link while another client changed it - gave a lower number than
* the attendees had, which 0.16 took and they refused, and a pointer into an
* override removed meanwhile failed the whole save. An override `series`
* rewrites, built on that copy, goes at least one above the series.
*/
function withRaisedSequences(series: Partial<CalendarEvent>, master: CalendarEvent): Partial<CalendarEvent> {
const base = master.sequence ?? 0;
const raised: Record<string, unknown> = { ...series, sequence: base + 1 };
const raise = (entry: OverrideEntry) => (entry.excluded ? entry : { ...entry, sequence: Math.max(Number(entry.sequence ?? base), base) + 1 });
if (series.recurrenceOverrides !== undefined) {
if (series.recurrenceOverrides) {
raised.recurrenceOverrides = Object.fromEntries(Object.entries(series.recurrenceOverrides)
.map(([key, entry]) => [key, raise(entry as OverrideEntry)]));
}
} else {
for (const [key, entry] of Object.entries(master.recurrenceOverrides ?? {})) {
if (!(entry as OverrideEntry).excluded) raised[`recurrenceOverrides/${key}/sequence`] = raise(entry as OverrideEntry).sequence;
}
}
return raised as Partial<CalendarEvent>;
}
/**
* Whether saving `series` for every occurrence of `master` raises their
* SEQUENCE (withRaisedSequences): the user organizes the series and tells
* its attendees of a change they see, and it has occurrences of its own a
* change of one of them may have left behind.
*/
function raisesSequences(series: Partial<CalendarEvent>, master: CalendarEvent, edit: OccurrenceEdit): boolean {
return !!edit.sendScheduling
&& !!edit.editor
&& Object.keys(master.participants ?? {}).length > 0
&& userOrganizes(master, edit.userEmails)
&& Object.values(master.recurrenceOverrides ?? {}).some((entry) => !(entry as OverrideEntry).excluded)
&& ANNOUNCED_KEYS.some((key) => key in series);
}
/** What saving an occurrence edit works with: the calendar store's actions and the app's series lookup. */
/**
* How an occurrence edit or deletion ended: true when it was made, false when
* the series could not be found, "stopped" when a plugin refused a write (and
* said why itself).
*/
export type OccurrenceOutcome = boolean | 'stopped';
/** The store actions resolve to false when a plugin stopped the write. */
function stopped(result: boolean | void): boolean {
return result === false;
}
export interface OccurrenceEditActions {
client: IJMAPClient;
updateEvent: (
client: IJMAPClient,
id: string,
updates: Partial<CalendarEvent>,
sendSchedulingMessages?: boolean,
options?: EventUpdateOptions,
) => Promise<boolean | 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; null when a plugin stopped
* the truncation.
*/
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 } | null> {
const originalRules = master.recurrenceRules
? JSON.parse(JSON.stringify(master.recurrenceRules))
: null;
const splitKey = splitKeyOf(occurrence, master);
const until = untilBefore(splitKey, master);
const truncatedRules = (master.recurrenceRules || []).map(rule => ({
...rule,
until,
count: null,
}));
const patch: Record<string, unknown> = { recurrenceRules: truncatedRules };
const restore: Partial<CalendarEvent> = { 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;
}
// Put back as the whole map: a pointer into it fails while the series has
// no overrides at all, which the truncation may just have left it with.
if (Object.keys(removed).length > 0) restore.recurrenceOverrides = withoutUpdated(master.recurrenceOverrides!);
const announced = scheduling.sendScheduling && userOrganizes(master, scheduling.userEmails ?? []) ? true : undefined;
if (stopped(await actions.updateEvent(actions.client, master.id, patch as Partial<CalendarEvent>, announced))) return null;
return { restore, announced, removed };
}
/**
* The UNTIL that ends `series` just before its occurrence at `splitKey`: a
* second before it, on the series' own clock - and earlier by as much as the
* series' zone is behind UTC. 0.16 (0.16.23 checked) takes the UNTIL for
* the series' wall clock, turns it into UTC and compares that with the
* wall clock of each occurrence: in New York "until 09:59:59" kept the
* 10:00 occurrence it was to end before, "until 04:59:59" ends before it.
* 1.0 reads the UNTIL on the series' clock, and ends it there either way as
* long as its occurrences are further apart than that - hours, for a daily
* or weekly series never. The offset is the larger of the split's day and
* the day before, for a change of daylight saving time between them.
*/
function untilBefore(splitKey: string, series: CalendarEvent): string {
let back = 1000;
const zone = !series.showWithoutTime && series.timeZone && isValidTimeZone(series.timeZone) ? series.timeZone : null;
if (zone) {
const instant = new Date(`${moveLocalDateTime(splitKey.slice(0, 19), zone, 'UTC')}Z`);
if (!Number.isNaN(instant.getTime())) {
const offsets = [instant, new Date(instant.getTime() - 86_400_000)].map((at) => getTimeZoneOffsetMs(at, zone));
back += Math.max(0, -Math.min(...offsets));
}
}
return shiftLocalDateTime(splitKey, -back);
}
/**
* 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 from the split on (truncateSeries),
* each with its occurrence (overrideKeysIn). 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. They take what the user changed in the editor
* as `series`, the old series, took it (withSeriesChanges). The occurrence
* the editor was opened on is the new series' first: moved on its own, it
* stays off the new series' rhythm by as much as before, plus the editor's
* move.
*/
function followingOverrides(
overrides: OverrideMap | undefined,
splitKey: string,
from: SeriesFrame,
to: SeriesFrame & { participants?: CalendarEvent['participants']; duration?: string },
series: CalendarEvent,
changes: Partial<CalendarEvent>,
newSeries: Partial<CalendarEvent>,
): OverrideMap | undefined {
if (!overrides || Object.keys(overrides).length === 0 || !to.recurrenceRules?.length) return undefined;
const turned = from.showWithoutTime !== to.showWithoutTime;
const resized = parseDurationSeconds(to.duration) !== parseDurationSeconds(series.duration);
const targets = overrideKeysIn(overrides, from, to, splitKey);
const result: OverrideMap = {};
for (const [key, override] of Object.entries(overrides)) {
const target = targets.get(key);
if (!target) continue;
const opened = key === splitKey;
// The new series starts at a SEQUENCE of its own. The old series' number
// came along, and 0.16.23 listed that occurrence twice in the new series,
// changed and as the series has it (live, organizer and attendee alike).
const { updated: _updated, sequence: _sequence, ...rest } = override as OverrideEntry;
let 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 (opened) {
entry.start = movedLike(entry.start, splitKey, to.start);
} 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 (!turned && resized && typeof entry.duration === 'string'
&& (opened || parseDurationSeconds(entry.duration) === parseDurationSeconds(series.duration))) {
entry.duration = to.duration;
}
entry = withSeriesChanges(entry, changes, series, opened);
if (seriesOccursAt(to.start, to.recurrenceRules, target) && changesNothing(entry, target, newSeries)) continue;
}
result[target] = entry as OverrideMap[string];
}
return Object.keys(result).length > 0 ? result : undefined;
}
/** What of the old series a new one made from it keeps: its details, where and when, and its reminders. */
const FOLLOWING_SERIES_KEYS = [
'title', 'description', 'descriptionContentType', 'locations', 'virtualLocations', 'links', 'keywords',
'categories', 'color', 'locale', 'status', 'freeBusyStatus', 'privacy', 'calendarIds', 'useDefaultAlerts',
'duration', 'timeZone', 'showWithoutTime',
] as const;
/**
* The new series "this and the following occurrences" becomes: the old
* series with what the user changed in the editor, 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. That
* goes for the user too when they attend it: 1.0 invites them like anyone
* else.
*
* It goes on where the old series would have: from the split occurrence's
* recurrence id, on the old series' clock and in its zone, moved only as far
* as the editor moved the occurrence (seriesWhen). It started at the
* occurrence's own time, in the user's zone: a Monday meeting in New York
* whose one occurrence had moved to Tuesday became a Tuesday series in
* Berlin from there on. What the editor showed of that occurrence alone -
* its own time, a title of its own - stays with it (followingOverrides).
* Without the editor's state the updates are the new series as they are.
*/
export function followingSeriesData(
occurrence: CalendarEvent,
master: CalendarEvent,
updates: Partial<CalendarEvent>,
originalRules: CalendarEvent['recurrenceRules'],
options: { editor?: EventEditorState; overrides?: OverrideMap } = {},
): Partial<CalendarEvent> {
const { editor } = options;
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 = editor
? !!editor.ruleChanged
: updates.recurrenceRules !== undefined && !sameRecurrenceRules(updates.recurrenceRules, originalRules);
const changes = editor ? changedEditorFields(occurrence, updates, editor) : updates;
const { recurrenceRules: editorRules, ...changed } = changes;
const data: Partial<CalendarEvent> = {};
for (const key of FOLLOWING_SERIES_KEYS) {
if (master[key] !== undefined) (data as Record<string, unknown>)[key] = master[key];
}
data.calendarIds = { ...master.calendarIds };
// While the calendar's default reminders are on, 1.0 lists them as the
// series' own: they come with the switch, not as copies.
if (!master.useDefaultAlerts && master.alerts) data.alerts = master.alerts;
Object.assign(data, changed);
data.recurrenceRules = ruleChanged && editorRules !== undefined ? editorRules : remainingRules(originalRules, master.start, splitKey);
if (editor) {
Object.assign(data, seriesWhen(occurrence, { ...master, start: splitKey }, updates, editor.when));
data.start ??= splitKey;
for (const key of ['participants', 'organizerCalendarAddress'] as const) {
if (updates[key] !== undefined) (data as Record<string, unknown>)[key] = updates[key];
}
} else {
data.start = updates.start || occurrence.recurrenceId || occurrence.start;
}
delete data.id;
delete data.uid;
delete data.recurrenceId;
delete data.recurrenceOverrides;
if (data.participants) {
// The series' organizer, by the exact address it is organized under: the
// editor writes one only for an event that had none. Created without it,
// 0.16.23 made the user who created the rest its organizer, whatever the
// owner participant says - a sharee who split the owner's meeting, the
// main address for a series organized under an alias. 1.0 takes the
// owner participant (live).
data.organizerCalendarAddress ??= master.organizerCalendarAddress;
data.participants = participantsAskedAgain({
participants: data.participants,
organizerCalendarAddress: data.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, duration: data.duration,
},
master, editor ? changed : {}, data,
);
if (overrides) data.recurrenceOverrides = overrides;
return data;
}
/**
* Change `occurrence` of `master`, which has no override yet, so that the
* change reaches every attendee who has not declined - in the two steps the
* calendar store takes for an occurrence of an expanded series (its
* updateNewOverrideForEveryone). Stalwart sends a new override only to
* attendees who have not answered, a change to an existing one to all who
* have not declined: the occurrence first gets its override as it is,
* without messages, and the change then goes out as a change of it. This
* is the series itself standing for its first occurrence - one opened from
* a link or a search - which changed in one write that reached nobody who
* had answered. The override is written on the series, under the key it
* keeps, so the second step needs no fresh read.
*/
async function overrideForEveryone(
occurrence: CalendarEvent,
master: CalendarEvent,
updates: Partial<CalendarEvent>,
actions: Pick<OccurrenceEditActions, 'client' | 'updateEvent'>,
): Promise<OccurrenceOutcome> {
const key = resolveOverrideKey(occurrence, occurrence.recurrenceOverrides);
const copy = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, {}, { seriesKeys: true }));
if (!key || !copy) return false;
if (stopped(await actions.updateEvent(actions.client, master.id, copy, false))) return 'stopped';
const written = (copy.recurrenceOverrides?.[key] ?? (copy as Record<string, unknown>)[`recurrenceOverrides/${key}`]) as OverrideMap[string];
const overridden = { ...occurrence, recurrenceOverrides: { ...(occurrence.recurrenceOverrides ?? {}), [key]: written } };
const change = buildFallbackOverridePatch(overridden, withNewOverrideDetails(overridden, updates, { seriesKeys: true }));
if (!change) return false;
if (stopped(await actions.updateEvent(actions.client, master.id, change, true))) return 'stopped';
return true;
}
/**
* Save the editor's change to one occurrence for `scope`. Resolves to false
* when the series the occurrence belongs to cannot be found, to "stopped"
* when a plugin refused a write.
*/
export async function saveOccurrenceEdit(
scope: RecurrenceEditScope,
edit: OccurrenceEdit,
actions: OccurrenceEditActions,
): Promise<OccurrenceOutcome> {
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);
if (stopped(await updateEvent(client, event.id, updates, sendScheduling, { asOrganizer }))) return 'stopped';
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 participants = updates.participants === undefined ? occurrence.participants : updates.participants;
if (sendScheduling && createsOverride(occurrence) && userOrganizes({ ...event, participants }, edit.userEmails)
&& hasAnsweredAttendees({ ...occurrence, participants })) {
return overrideForEveryone(occurrence, master, updates, actions);
}
const overridePatch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true }));
if (!overridePatch) return false;
if (stopped(await updateEvent(client, master.id, overridePatch, sendScheduling))) return 'stopped';
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;
if (stopped(await updateEvent(client, event.id, updates, sendScheduling))) return 'stopped';
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 truncated = await truncateSeries(master, event, actions, { sendScheduling, userEmails: edit.userEmails });
if (!truncated) return 'stopped';
const { restore, announced, removed } = truncated;
const newEventData = followingSeriesData(event, master, updates, restore.recurrenceRules ?? null, {
editor: edit.editor,
overrides: removed,
});
try {
// The calendar store answers most failures with null, and reports
// them itself: the series was left ended, and the rest gone for
// everyone, while the save said it had worked.
const created = await createEvent(client, newEventData, sendScheduling);
if (!created) throw new Error('Failed to create the rest of the series');
} 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;
}
if (raisesSequences(seriesUpdates, master, edit)) {
const series = seriesUpdates;
const raised = await updateEvent(client, master.id, withRaisedSequences(series, master), sendScheduling, {
keepSequence: true,
fromCurrent: (current) => withRaisedSequences(series, current),
});
return stopped(raised) ? 'stopped' : true;
}
if (stopped(await updateEvent(client, master.id, seriesUpdates, sendScheduling))) return 'stopped';
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<boolean | 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,
* to "stopped" when a plugin refused a write.
*/
export async function deleteOccurrence(
scope: RecurrenceEditScope,
deletion: { event: CalendarEvent; sendScheduling?: boolean; userEmails: string[] },
actions: OccurrenceDeleteActions,
): Promise<OccurrenceOutcome> {
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.
if (stopped(await deleteEvent(client, event.id, sendScheduling))) return 'stopped';
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;
if (stopped(await updateEvent(client, master.id, excludePatch, sendScheduling))) return 'stopped';
return true;
}
if (isSeries(event)) return false;
if (stopped(await deleteEvent(client, event.id, sendScheduling))) return 'stopped';
return true;
}
case 'this_and_future': {
const master = await findMasterEvent(event);
if (!master) return false;
if (isFirstOccurrence(event, master)) {
if (stopped(await deleteEvent(client, master.id, sendScheduling))) return 'stopped';
return true;
}
const truncated = await truncateSeries(master, event, actions, { sendScheduling, userEmails: deletion.userEmails });
return truncated ? true : 'stopped';
}
case 'all': {
const master = await findMasterEvent(event);
if (!master) return false;
if (stopped(await deleteEvent(client, master.id, sendScheduling))) return 'stopped';
return true;
}
default: {
const exhaustive: never = scope;
throw new Error(`Unhandled scope: ${exhaustive}`);
}
}
}