mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Edits of a recurring meeting from one of its occurrences went wrong in several ways (live on 1.0 and 0.16.23, attendees included): - A "this and following" split whose new series could not be created stayed half done: the calendar store answers most failures with null, and only a thrown error put the old series back. The series stayed ended, told so to the attendees, and the save said it had worked. A null now puts the series back as well and fails the save. The rollback writes the old overrides back as a whole map: a pointer into a series without overrides fails, and the split may just have removed its only one. - "This occurrence" on the series itself (a meeting opened from a link or a search result) went out in one write that reached no attendee who had answered. It now takes the same two steps as an occurrence of an expanded series: the override as it is without messages, then the change of it. - The new series of a split started at the opened occurrence's own time and in the user's zone: Mondays in New York, one occurrence moved to Tuesday, renamed from there by a user in Berlin, became a Tuesday series in Berlin. It now goes on from that occurrence's recurrence id, in the series' zone, moved only by what the editor moved; the occurrence keeps its own time through its override. It takes the old series' details with what the user changed, not the opened occurrence's own ones. - "All events" from an occurrence with an override of its own wrote that occurrence's own title, copied into its override, back to the series: a new room there renamed the series. Only the fields the user changed go to the series now; the opened occurrence's override takes them too (on 1.0 also where it holds them as pointers), and other overrides where they only held the series' old value. - The move of an "all" edit was measured on the user's clock: across a week in which only one of the two zones changes to or from summer time, the series moved an hour more or less than picked. It is now measured on the series' own clock. - 0.16 (0.16.23 checked) reads a rule's UNTIL as the series' wall clock taken for UTC, so in a zone behind UTC a split or "delete this and following" kept the occurrence it was to end before. The UNTIL is now earlier by the zone's offset, which ends the series there on 0.16 and 1.0 alike. - An applied counter proposal for one occurrence of a recurring meeting went to the first event with its uid. It now goes to the occurrence the proposal names; one for a whole series of which only occurrences are in view is not applied to any of them.
704 lines
30 KiB
TypeScript
704 lines
30 KiB
TypeScript
/**
|
|
* Server-side recurrence instances ("synthetic ids").
|
|
*
|
|
* `CalendarEvent/query` with `expandRecurrences: true` returns one synthetic
|
|
* id per occurrence in the range instead of the stored base event. Since
|
|
* Stalwart 0.16.20 those ids are also accepted by `CalendarEvent/set`: an
|
|
* update on a synthetic id writes a `recurrenceOverrides` entry for that one
|
|
* occurrence, a destroy excludes it. Older servers reject both with
|
|
* `invalidProperties` ("… synthetic ids is not yet supported."), in which
|
|
* case Bulwark keeps expanding recurrences in the browser
|
|
* (lib/recurrence-expansion.ts) and patches the base event itself.
|
|
*
|
|
* What an expanded occurrence looks like (verified against 0.16.19):
|
|
* - `id` is synthetic, `baseEventId` is the stored event's id; a
|
|
* non-recurring event in the range gets a synthetic id too.
|
|
* - `recurrenceId` / `recurrenceIdTimeZone` are set for occurrences of a
|
|
* series, `recurrenceRule` / `recurrenceOverrides` are stripped.
|
|
* - all-day occurrences lose `showWithoutTime` and are stamped with the
|
|
* request time zone.
|
|
* - the ids are positional and reshuffle whenever the series' overrides
|
|
* change, so the visible range has to be refetched after mutating one.
|
|
*/
|
|
|
|
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
|
import { parseDurationSeconds } from '@/lib/calendar-event-normalization';
|
|
import { jmapErrorType } from '@/lib/jmap/jmap-error';
|
|
import { pointerTokenValue } from '@/lib/jmap/patch-pointer';
|
|
import { RECURRENCE_OVERRIDE_IMMUTABLE_KEYS } from '@/lib/recurrence-overrides';
|
|
|
|
/**
|
|
* A synthetic id whose event can never exist: expansion 0 of document
|
|
* `u32::MAX` (`Id::from_parts(1, u32::MAX)` in Stalwart's base32 alphabet).
|
|
* A pre-0.16.20 server rejects an update on it as `invalidProperties`
|
|
* before looking anything up; a server that supports synthetic ids answers
|
|
* `notFound`. Either way nothing is written, which makes it a safe probe.
|
|
*/
|
|
export const SYNTHETIC_ID_PROBE = 'h333333';
|
|
|
|
/** Base-event properties an expanded occurrence needs back (see `hydrateRecurrenceInstances`). */
|
|
export const RECURRENCE_BASE_PROPERTIES = [
|
|
'id',
|
|
'recurrenceRule',
|
|
'excludedRecurrenceRule',
|
|
'recurrenceOverrides',
|
|
'showWithoutTime',
|
|
'timeZone',
|
|
'duration',
|
|
'title',
|
|
'locations',
|
|
'description',
|
|
] as const;
|
|
|
|
/**
|
|
* Properties Stalwart refuses on a single occurrence ("This property cannot
|
|
* be modified on a single occurrence."). They describe the stored event, not
|
|
* one of its instances.
|
|
*/
|
|
export const OCCURRENCE_REJECTED_KEYS = [
|
|
'baseEventId',
|
|
'calendarIds',
|
|
'isDraft',
|
|
'isOrigin',
|
|
'utcStart',
|
|
'utcEnd',
|
|
'useDefaultAlerts',
|
|
'mayInviteSelf',
|
|
'mayInviteOthers',
|
|
'hideAttendees',
|
|
] as const;
|
|
|
|
/**
|
|
* Properties of the whole series, plus the client's plural rule names.
|
|
* Stalwart 1.0 refuses them inside a recurrence override, and on a synthetic
|
|
* id unless they equal the base event's value; 0.16.20 to 0.16.23 drop them
|
|
* there (jmap/src/calendar_event/validate.rs, is_series_property).
|
|
*/
|
|
export const SERIES_KEYS = [
|
|
'@type',
|
|
'method',
|
|
'organizerCalendarAddress',
|
|
'privacy',
|
|
'prodId',
|
|
'recurrenceId',
|
|
'recurrenceIdTimeZone',
|
|
'sentBy',
|
|
'uid',
|
|
'recurrenceOverrides',
|
|
'recurrenceRule',
|
|
'recurrenceRules',
|
|
'excludedRecurrenceRule',
|
|
'excludedRecurrenceRules',
|
|
] as const;
|
|
|
|
/** Per-event metadata (validate.rs, is_event_metadata): never part of one occurrence. */
|
|
export const EVENT_METADATA_KEYS = ['id', ...OCCURRENCE_REJECTED_KEYS] as const;
|
|
|
|
type EventIdentity = Pick<CalendarEvent, 'id'> & Partial<Pick<CalendarEvent, 'originalId' | 'baseEventId'>>;
|
|
|
|
/**
|
|
* True for an occurrence handed out by server-side expansion, i.e. an event
|
|
* whose (raw) id is synthetic and differs from its base event's id. A base
|
|
* event fetched directly also carries `baseEventId`, equal to its own id.
|
|
*/
|
|
export function isServerRecurrenceInstance(event: EventIdentity | null | undefined): boolean {
|
|
if (!event?.baseEventId) return false;
|
|
return event.baseEventId !== (event.originalId ?? event.id);
|
|
}
|
|
|
|
/**
|
|
* The store id under which the base event of `instance` would be addressed.
|
|
* Store ids end with the raw JMAP id (`<accountId>:<raw>`,
|
|
* `<localAccountId>::<raw>` or just `<raw>`), so swap the trailing raw
|
|
* synthetic id for the base id and keep whatever namespace prefix is there.
|
|
*/
|
|
export function baseEventStoreId(instance: EventIdentity): string | null {
|
|
if (!isServerRecurrenceInstance(instance) || !instance.baseEventId) return null;
|
|
const raw = instance.originalId ?? instance.id;
|
|
if (!instance.id.endsWith(raw)) return instance.baseEventId;
|
|
return instance.id.slice(0, instance.id.length - raw.length) + instance.baseEventId;
|
|
}
|
|
|
|
/**
|
|
* Whether a `CalendarEvent/set` failure is a pre-0.16.20 server refusing a
|
|
* synthetic id ("Updating/Deleting synthetic ids is not yet supported.").
|
|
*/
|
|
export function isSyntheticIdMutationUnsupported(error: unknown): boolean {
|
|
const message = error instanceof Error
|
|
? error.message
|
|
: typeof error === 'string' ? error : '';
|
|
return /synthetic ids?\b.*\bnot (?:yet )?supported/i.test(message);
|
|
}
|
|
|
|
function firstPointerSegment(key: string): string {
|
|
const slash = key.indexOf('/');
|
|
return slash === -1 ? key : key.slice(0, slash);
|
|
}
|
|
|
|
const NOT_PER_OCCURRENCE = new Set<string>([...SERIES_KEYS, ...EVENT_METADATA_KEYS]);
|
|
|
|
// A participant's address is the series': 1.0 refuses a pointer at it inside
|
|
// an override, and on a synthetic id unless it is unchanged.
|
|
const PARTICIPANT_ADDRESS_POINTER = /^participants\/[^/]+\/calendarAddress$/;
|
|
|
|
function isPerOccurrenceKey(key: string): boolean {
|
|
return !NOT_PER_OCCURRENCE.has(firstPointerSegment(key)) && !PARTICIPANT_ADDRESS_POINTER.test(key);
|
|
}
|
|
|
|
/**
|
|
* Reduce an event patch to what one occurrence may carry, through its
|
|
* synthetic id or inside its override: series keys and per-event metadata
|
|
* are dropped (also when addressed through a JSON pointer such as
|
|
* `calendarIds/x`), and so is a pointer at a participant's calendarAddress.
|
|
* Everything else - including pointer patches like `locations/loc1/name` and
|
|
* a whole `participants` map - passes through unchanged.
|
|
*/
|
|
export function buildOccurrencePatch(updates: Partial<CalendarEvent>): Partial<CalendarEvent> {
|
|
const patch: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
if (isPerOccurrenceKey(key)) patch[key] = value;
|
|
}
|
|
return patch as Partial<CalendarEvent>;
|
|
}
|
|
|
|
const NOT_IN_OVERRIDE_ENTRIES = new Set<string>([
|
|
...RECURRENCE_OVERRIDE_IMMUTABLE_KEYS,
|
|
...OCCURRENCE_REJECTED_KEYS,
|
|
'recurrenceId',
|
|
'recurrenceIdTimeZone',
|
|
]);
|
|
|
|
/**
|
|
* The fields of an override entry on the base event, as 0.16 has always been
|
|
* sent them: identity and per-event keys dropped, series keys such as privacy
|
|
* and the organizer kept. 0.16.22 and older store them on the override, and
|
|
* 0.16.19 only accepts an answer to one occurrence that names the organizer.
|
|
* Stalwart 1.0 refuses them there; see withoutOverrideSeriesKeys.
|
|
*/
|
|
function overrideEntryFields(updates: Partial<CalendarEvent>): Record<string, unknown> {
|
|
const fields: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
if (!NOT_IN_OVERRIDE_ENTRIES.has(firstPointerSegment(key))) fields[key] = value;
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
/**
|
|
* `updates` of a base event with the series and per-event keys taken out of
|
|
* the recurrence overrides it writes, whole entries and pointers into one
|
|
* alike. What Stalwart 1.0 accepts inside an override; the rest of the
|
|
* update is left alone.
|
|
*/
|
|
export function withoutOverrideSeriesKeys(updates: Partial<CalendarEvent>): Partial<CalendarEvent> {
|
|
const entry = (value: unknown) => value && typeof value === 'object'
|
|
? buildOccurrencePatch(value as Partial<CalendarEvent>)
|
|
: value;
|
|
const result: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
if (key === 'recurrenceOverrides' && value && typeof value === 'object') {
|
|
result[key] = Object.fromEntries(Object.entries(value).map(([id, override]) => [id, entry(override)]));
|
|
} else if (key.startsWith('recurrenceOverrides/')) {
|
|
const inEntry = key.split('/').slice(2).join('/');
|
|
if (!inEntry) result[key] = entry(value);
|
|
else if (isPerOccurrenceKey(inEntry)) result[key] = value;
|
|
} else {
|
|
result[key] = value;
|
|
}
|
|
}
|
|
return result as Partial<CalendarEvent>;
|
|
}
|
|
|
|
/**
|
|
* Whether a CalendarEvent/set failure is Stalwart 1.0 refusing a series key
|
|
* inside a recurrence override (invalidProperties, "privacy cannot be set
|
|
* inside recurrenceOverrides.").
|
|
*/
|
|
export function isOverrideSeriesKeyRejection(error: unknown): boolean {
|
|
return jmapErrorType(error) === 'invalidProperties'
|
|
&& /cannot be set inside recurrenceOverrides/i.test(error instanceof Error ? error.message : '');
|
|
}
|
|
|
|
type OverrideMap = NonNullable<CalendarEvent['recurrenceOverrides']>;
|
|
|
|
/**
|
|
* Given an expanded occurrence, find the key of its entry in the base
|
|
* event's `recurrenceOverrides`. Normally that is `recurrenceId`; a
|
|
* pre-0.16.20 server reports a moved occurrence's *new* start as its
|
|
* recurrenceId, in which case the entry is found by that start instead.
|
|
*/
|
|
export function resolveOverrideKey(
|
|
instance: Pick<CalendarEvent, 'start' | 'recurrenceId'>,
|
|
overrides: OverrideMap | null | undefined,
|
|
): string | null {
|
|
if (!instance.recurrenceId) return null;
|
|
if (!overrides || instance.recurrenceId in overrides) return instance.recurrenceId;
|
|
for (const [key, override] of Object.entries(overrides)) {
|
|
if (override && override.start === instance.start) return key;
|
|
}
|
|
return instance.recurrenceId;
|
|
}
|
|
|
|
/** `value` as JSON with the members of every object in a fixed order: equal values, equal strings. */
|
|
export function stableJson(value: unknown): string {
|
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
if (value && typeof value === 'object') {
|
|
const entries = Object.entries(value as Record<string, unknown>)
|
|
.filter(([, v]) => v !== undefined)
|
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableJson(v)}`).join(',')}}`;
|
|
}
|
|
return JSON.stringify(value ?? null);
|
|
}
|
|
|
|
/**
|
|
* An override's participants, each completed from the series participant
|
|
* with the same id or calendar address. The override decides who takes part;
|
|
* the series fills what it leaves out. Stalwart records an attendee's reply to
|
|
* one occurrence as an override naming the organizer under a new id and
|
|
* without a participation status, which would otherwise show the organizer as
|
|
* not having answered their own meeting.
|
|
*/
|
|
export function mergeOverrideParticipants(
|
|
series: CalendarEvent['participants'],
|
|
override: CalendarEvent['participants'],
|
|
): CalendarEvent['participants'] {
|
|
if (!override) return override;
|
|
const address = (p: CalendarParticipant) => (p.calendarAddress ?? '').toLowerCase();
|
|
const from = series ?? {};
|
|
const merged: Record<string, CalendarParticipant> = {};
|
|
for (const [id, participant] of Object.entries(override)) {
|
|
const match = id in from
|
|
? id
|
|
: Object.keys(from).find((key) => address(from[key]) && address(from[key]) === address(participant));
|
|
const defined = Object.fromEntries(Object.entries(participant).filter(([, v]) => v != null));
|
|
merged[match ?? id] = { ...(match ? from[match] : {}), ...defined } as CalendarParticipant;
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
/** The members a PatchObject key points at (RFC 6901 tokens, decoded). */
|
|
function pointerPath(key: string): string[] {
|
|
return key.split('/').map(pointerTokenValue);
|
|
}
|
|
|
|
/**
|
|
* A copy of `target` with the member at `path` set to `value` - or removed,
|
|
* when `value` is null - copying every object on the way. Removing beneath
|
|
* a member that is not there changes nothing; setting creates it.
|
|
*/
|
|
function patchedAt(target: object, path: string[], value: unknown): Record<string, unknown> {
|
|
const copy = (Array.isArray(target) ? [...target] : { ...target }) as Record<string, unknown>;
|
|
const [name, ...rest] = path;
|
|
if (rest.length === 0) {
|
|
if (value === null) delete copy[name];
|
|
else copy[name] = value;
|
|
return copy;
|
|
}
|
|
const child = copy[name];
|
|
if (child !== null && typeof child === 'object') copy[name] = patchedAt(child, rest, value);
|
|
else if (value !== null) copy[name] = patchedAt({}, rest, value);
|
|
return copy;
|
|
}
|
|
|
|
function valueAt(target: unknown, key: string): unknown {
|
|
let value = target;
|
|
for (const name of pointerPath(key)) {
|
|
if (value === null || typeof value !== 'object') return undefined;
|
|
value = (value as Record<string, unknown>)[name];
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* `event` with a recurrence override applied. An override is an RFC 8984
|
|
* PatchObject: every key a JSON Pointer into the occurrence, null removing
|
|
* what it points at. Stalwart 1.0 returns minimal ones such as
|
|
* `participants/p1/participationStatus`; 0.16 returns whole top-level
|
|
* values, which apply the same way. Shorter paths go first, so a pointer
|
|
* refines a value the same patch sets whole, and a whole `participants` map
|
|
* is completed from the series (mergeOverrideParticipants).
|
|
*/
|
|
export function applyOverridePatch<T extends Partial<CalendarEvent>>(event: T, patch: Record<string, unknown>): T {
|
|
let result: Record<string, unknown> = { ...event };
|
|
const keys = Object.keys(patch).sort((a, b) => a.split('/').length - b.split('/').length);
|
|
for (const key of keys) {
|
|
const value = patch[key];
|
|
if (key === 'participants' && value) {
|
|
result.participants = mergeOverrideParticipants(
|
|
result.participants as CalendarEvent['participants'],
|
|
value as CalendarEvent['participants'],
|
|
);
|
|
} else {
|
|
result = patchedAt(result, pointerPath(key), value);
|
|
}
|
|
}
|
|
return result as T;
|
|
}
|
|
|
|
/**
|
|
* An override entry with `updates` merged in, without keys that are prefixes
|
|
* of one another (RFC 8984 does not allow those in one PatchObject): a key
|
|
* replaces whatever the entry patches beneath it, and one beneath a value the
|
|
* entry sets goes into that value.
|
|
*/
|
|
function mergeOverrideEntry(entry: Record<string, unknown>, updates: Record<string, unknown>): Record<string, unknown> {
|
|
const merged = { ...entry };
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
for (const existing of Object.keys(merged)) {
|
|
if (existing.startsWith(`${key}/`)) delete merged[existing];
|
|
}
|
|
const holder = Object.keys(merged).find((existing) => key.startsWith(`${existing}/`));
|
|
const held = holder === undefined ? undefined : merged[holder];
|
|
if (holder !== undefined && held !== null && typeof held === 'object') {
|
|
merged[holder] = patchedAt(held, pointerPath(key.slice(holder.length + 1)), value);
|
|
continue;
|
|
}
|
|
if (holder !== undefined) delete merged[holder];
|
|
merged[key] = value;
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Stalwart lists an occurrence twice under one id - once as the series
|
|
* generates it, once from its override - when the override ranks below the
|
|
* series (no or a lower SEQUENCE). It writes such overrides itself: an
|
|
* attendee's reply to one occurrence becomes, on the organizer's side, an
|
|
* override holding little more than that attendee. Collapse each pair into
|
|
* one occurrence: the fields the override defines (per the base event's
|
|
* overrides map) from the override, the rest from the series.
|
|
*/
|
|
function collapseDuplicateInstances(
|
|
instances: CalendarEvent[],
|
|
bases: ReadonlyMap<string, Partial<CalendarEvent>>,
|
|
): CalendarEvent[] {
|
|
const groups = new Map<string, CalendarEvent[]>();
|
|
for (const instance of instances) {
|
|
const group = groups.get(instance.id);
|
|
if (group) group.push(instance);
|
|
else groups.set(instance.id, [instance]);
|
|
}
|
|
if (groups.size === instances.length) return instances;
|
|
|
|
const collapse = (group: CalendarEvent[]): CalendarEvent => {
|
|
const first = group[0];
|
|
const base = first.baseEventId ? bases.get(first.baseEventId) : undefined;
|
|
const key = base ? resolveOverrideKey(first, base.recurrenceOverrides) : null;
|
|
const entry = key ? base?.recurrenceOverrides?.[key] as Record<string, unknown> | undefined : undefined;
|
|
if (!entry) {
|
|
return group.reduce((a, b) => (Object.keys(b).length > Object.keys(a).length ? b : a));
|
|
}
|
|
// The entry may patch members (Stalwart 1.0 returns overrides as
|
|
// PatchObjects such as `participants/p1/participationStatus`): score each
|
|
// instance by the members it matches, and take the override's whole
|
|
// top-level values for what the entry touches.
|
|
const patchKeys = Object.keys(entry).filter((k) => k !== 'updated');
|
|
const score = (instance: CalendarEvent) => patchKeys
|
|
.filter((k) => stableJson(valueAt(instance, k)) === stableJson(entry[k]))
|
|
.length;
|
|
const override = group.reduce((a, b) => (score(b) > score(a) ? b : a));
|
|
const series = group.find((instance) => instance !== override) ?? first;
|
|
const merged: Record<string, unknown> = { ...series };
|
|
for (const k of new Set(patchKeys.map(firstPointerSegment))) {
|
|
merged[k] = k === 'participants'
|
|
? mergeOverrideParticipants(series.participants, override.participants)
|
|
: (override as unknown as Record<string, unknown>)[k];
|
|
}
|
|
return merged as unknown as CalendarEvent;
|
|
};
|
|
|
|
const seen = new Set<string>();
|
|
const result: CalendarEvent[] = [];
|
|
for (const instance of instances) {
|
|
if (seen.has(instance.id)) continue;
|
|
seen.add(instance.id);
|
|
const group = groups.get(instance.id)!;
|
|
result.push(group.length > 1 ? collapse(group) : instance);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Complete an occurrence built from a reply stub. 0.16 stored an attendee's
|
|
* answer to one occurrence as an override holding little more than that
|
|
* attendee - no SUMMARY, LOCATION or DESCRIPTION - and Stalwart 1.0 builds
|
|
* such an occurrence from the override alone, so it shows untitled. The
|
|
* title comes from the series unless the override sets one itself; the
|
|
* place and description only when the override removes the title, which is
|
|
* what a stub looks like as 1.0 returns it (`title: null` in the PatchObject).
|
|
*/
|
|
function fillReplyStub(
|
|
occurrence: CalendarEvent,
|
|
base: Partial<CalendarEvent>,
|
|
override: Partial<CalendarEvent> | undefined,
|
|
): void {
|
|
if (!occurrence.title && base.title && typeof override?.title !== 'string') {
|
|
occurrence.title = base.title;
|
|
}
|
|
if (override?.title !== null) return;
|
|
if (occurrence.locations == null && base.locations) occurrence.locations = base.locations;
|
|
if (!occurrence.description && base.description) occurrence.description = base.description;
|
|
}
|
|
|
|
function shiftIso(from: string | null | undefined, seconds: number): string | null {
|
|
if (!from) return null;
|
|
const ms = Date.parse(from);
|
|
if (Number.isNaN(ms)) return null;
|
|
return new Date(ms + seconds * 1000).toISOString();
|
|
}
|
|
|
|
/**
|
|
* Give expanded occurrences the base-event context the rest of the app
|
|
* expects from an occurrence (and that the client-side expansion used to
|
|
* copy from the master): the recurrence rules and overrides, the all-day
|
|
* flag, and - for an override that does not set its own duration - the
|
|
* inherited duration (RFC 8984 §4.3.4: an override is a patch on the base
|
|
* event; the server's own computed duration for such overrides has been seen
|
|
* to be wrong). A title the occurrence lacks comes from the series as well
|
|
* (see fillReplyStub). Occurrences whose base event is not in `bases` are
|
|
* returned unchanged.
|
|
*/
|
|
export function hydrateRecurrenceInstances(
|
|
instances: CalendarEvent[],
|
|
bases: ReadonlyMap<string, Partial<CalendarEvent>>,
|
|
): CalendarEvent[] {
|
|
return collapseDuplicateInstances(instances, bases).map((instance) => {
|
|
if (!isServerRecurrenceInstance(instance) || !instance.recurrenceId) return instance;
|
|
const base = instance.baseEventId ? bases.get(instance.baseEventId) : undefined;
|
|
if (!base) return instance;
|
|
|
|
const hydrated: CalendarEvent = {
|
|
...instance,
|
|
recurrenceRules: base.recurrenceRules ?? null,
|
|
excludedRecurrenceRules: base.excludedRecurrenceRules ?? null,
|
|
recurrenceOverrides: base.recurrenceOverrides ?? null,
|
|
};
|
|
if (base.showWithoutTime) {
|
|
hydrated.showWithoutTime = true;
|
|
hydrated.timeZone = base.timeZone ?? null;
|
|
}
|
|
|
|
const overrideKey = resolveOverrideKey(instance, base.recurrenceOverrides);
|
|
if (overrideKey) hydrated.recurrenceId = overrideKey;
|
|
const override = overrideKey ? base.recurrenceOverrides?.[overrideKey] : undefined;
|
|
fillReplyStub(hydrated, base, override);
|
|
if (override && override.duration == null && base.duration && hydrated.duration !== base.duration) {
|
|
hydrated.duration = base.duration;
|
|
const seconds = parseDurationSeconds(base.duration);
|
|
if (seconds !== null && !hydrated.showWithoutTime) {
|
|
hydrated.utcEnd = shiftIso(hydrated.utcStart, seconds) ?? hydrated.utcEnd;
|
|
}
|
|
}
|
|
return hydrated;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* An occurrence of a series that the browser expanded itself
|
|
* (lib/recurrence-expansion.ts), as opposed to one the server handed out.
|
|
* Changes to it are written as a recurrence override on its base event.
|
|
*/
|
|
export function isBrowserExpandedOccurrence(
|
|
event: Pick<CalendarEvent, 'id' | 'recurrenceId' | 'recurrenceRules'> & Partial<Pick<CalendarEvent, 'originalId' | 'baseEventId'>>,
|
|
): boolean {
|
|
return !!event.recurrenceId && (event.recurrenceRules?.length ?? 0) > 0 && !isServerRecurrenceInstance(event);
|
|
}
|
|
|
|
type OccurrenceOverrideContext = Pick<CalendarEvent, 'id' | 'start' | 'recurrenceId' | 'recurrenceOverrides'>
|
|
& Partial<Pick<CalendarEvent, 'originalId' | 'baseEventId'>>;
|
|
|
|
/**
|
|
* `occurrence` as seen from `master`, the base event it belongs to, for
|
|
* building an override patch on the master: the master's overrides map is
|
|
* the one that counts.
|
|
*/
|
|
export function overrideContextOf(
|
|
occurrence: CalendarEvent,
|
|
master: Pick<CalendarEvent, 'recurrenceOverrides'>,
|
|
): CalendarEvent {
|
|
return { ...occurrence, baseEventId: undefined, recurrenceOverrides: master.recurrenceOverrides ?? null };
|
|
}
|
|
|
|
/**
|
|
* The base event's overrides map as far as `occurrence` knows it, or
|
|
* `undefined` when it does not. The browser's expansion copies the base
|
|
* event's map (absent means none); a server occurrence only has it once
|
|
* hydrated from its base event (`null` then means none).
|
|
*/
|
|
function knownOverrides(occurrence: Partial<CalendarEvent>): OverrideMap | null | undefined {
|
|
if (isServerRecurrenceInstance({ ...occurrence, id: occurrence.id ?? '' })) return occurrence.recurrenceOverrides;
|
|
return occurrence.recurrenceOverrides ?? null;
|
|
}
|
|
|
|
/** True when a change to `occurrence` creates its override, false when one exists or it is unknown. */
|
|
export function createsOverride(occurrence: Partial<CalendarEvent>): boolean {
|
|
const overrides = knownOverrides(occurrence);
|
|
if (overrides === undefined || !occurrence.recurrenceId) return false;
|
|
const key = resolveOverrideKey({ start: occurrence.start ?? '', recurrenceId: occurrence.recurrenceId }, overrides);
|
|
return !!key && !(overrides && key in overrides);
|
|
}
|
|
|
|
/**
|
|
* `override` as the entry `key` of the base event's overrides. A
|
|
* `recurrenceOverrides/<key>` pointer fails ("Patch operation failed") while
|
|
* the base event has no overrides at all, so the first one is sent as the
|
|
* whole map - but only when the map is known to be empty.
|
|
*/
|
|
function overrideEntryPatch(
|
|
instance: OccurrenceOverrideContext,
|
|
key: string,
|
|
override: Record<string, unknown>,
|
|
): Partial<CalendarEvent> {
|
|
const overrides = knownOverrides(instance);
|
|
if (overrides !== undefined && Object.keys(overrides ?? {}).length === 0) {
|
|
return { recurrenceOverrides: { [key]: override } } as Partial<CalendarEvent>;
|
|
}
|
|
return { [`recurrenceOverrides/${key}`]: override } as Partial<CalendarEvent>;
|
|
}
|
|
|
|
/**
|
|
* A change to one occurrence expressed as a recurrence override on its base
|
|
* event: used for occurrences the browser expanded, and as the fallback for
|
|
* servers that reject synthetic ids. The existing override (if any) is kept
|
|
* underneath so a partial patch (a drag that only sets `start`) does not wipe
|
|
* fields the occurrence already overrides, and `start`/`duration` are pinned
|
|
* the way the server does it for synthetic-id updates.
|
|
*/
|
|
export function buildFallbackOverridePatch(
|
|
instance: OccurrenceOverrideContext & Pick<CalendarEvent, 'duration'>,
|
|
patch: Partial<CalendarEvent>,
|
|
): Partial<CalendarEvent> | null {
|
|
const key = resolveOverrideKey(instance, instance.recurrenceOverrides);
|
|
if (!key) return null;
|
|
const existing = (instance.recurrenceOverrides?.[key] ?? {}) as Record<string, unknown>;
|
|
const { updated: _updated, ...kept } = existing;
|
|
return overrideEntryPatch(instance, key, mergeOverrideEntry(kept, {
|
|
start: instance.start,
|
|
duration: instance.duration,
|
|
...overrideEntryFields(patch),
|
|
}));
|
|
}
|
|
|
|
/** Destroying one occurrence as an override on its base event: exclude it. */
|
|
export function buildFallbackExcludePatch(
|
|
instance: OccurrenceOverrideContext,
|
|
): Partial<CalendarEvent> | null {
|
|
const key = resolveOverrideKey(instance, instance.recurrenceOverrides);
|
|
if (!key) return null;
|
|
return overrideEntryPatch(instance, key, { excluded: true });
|
|
}
|
|
|
|
/**
|
|
* What a new override copies from its occurrence, unless the change sets it
|
|
* itself. Stalwart stores an override as a VEVENT holding only the override's
|
|
* own properties, so without these the occurrence loses its title, place,
|
|
* attendees and reminders - for other CalDAV clients as well as when Stalwart
|
|
* expands it. `sequence` keeps the override from ranking below the series:
|
|
* an override with a lower SEQUENCE does not replace its occurrence and
|
|
* Stalwart then lists that occurrence twice (the organizer's server raises
|
|
* the sequence of a changed override from this value).
|
|
*/
|
|
const NEW_OVERRIDE_COPIED_KEYS = [
|
|
'title',
|
|
'description',
|
|
'descriptionContentType',
|
|
'locations',
|
|
'virtualLocations',
|
|
'links',
|
|
'keywords',
|
|
'categories',
|
|
'color',
|
|
'locale',
|
|
'status',
|
|
'freeBusyStatus',
|
|
'alerts',
|
|
'participants',
|
|
'sequence',
|
|
] as const;
|
|
|
|
/**
|
|
* Series keys that a new override on the base event carries as well, the way
|
|
* 0.16 has always been sent it (see overrideEntryFields). An override through
|
|
* a synthetic id never copies them: they are the series' own, 1.0 refuses a
|
|
* different value and 0.16.20 to 0.16.23 drop them.
|
|
*/
|
|
const NEW_OVERRIDE_SERIES_KEYS = ['privacy', 'organizerCalendarAddress'] as const;
|
|
|
|
export interface OverridePatchOptions {
|
|
/** Include the series keys, for an override written on the base event. */
|
|
seriesKeys?: boolean;
|
|
}
|
|
|
|
/**
|
|
* `patch` for an occurrence's existing override on the base event, with the
|
|
* series' organizer when that override names none - as every editor save
|
|
* wrote it before 4c20b09c: 0.16.19 refuses an answer to one occurrence
|
|
* whose override has no organizer (see buildOccurrenceRsvpPatch). Stalwart
|
|
* 1.0 refuses the key inside an override, and the store then writes the
|
|
* override once more without series keys; through a synthetic id it is
|
|
* never sent.
|
|
*/
|
|
function withOverrideOrganizer(occurrence: Partial<CalendarEvent>, patch: Partial<CalendarEvent>): Partial<CalendarEvent> {
|
|
if (!occurrence.organizerCalendarAddress || 'organizerCalendarAddress' in patch || !occurrence.recurrenceId) return patch;
|
|
const overrides = knownOverrides(occurrence);
|
|
const key = resolveOverrideKey({ start: occurrence.start ?? '', recurrenceId: occurrence.recurrenceId }, overrides);
|
|
const entry = key ? overrides?.[key] : undefined;
|
|
if (!entry || 'organizerCalendarAddress' in entry) return patch;
|
|
return { ...patch, organizerCalendarAddress: occurrence.organizerCalendarAddress };
|
|
}
|
|
|
|
/**
|
|
* `patch` plus the occurrence details a new override needs
|
|
* (NEW_OVERRIDE_COPIED_KEYS). Nothing is added when the occurrence already
|
|
* has an override - an attendee may not add most of these to the organizer's
|
|
* - or when that is unknown; an existing override written on the base event
|
|
* gets the series' organizer if it has none (withOverrideOrganizer).
|
|
*/
|
|
export function withNewOverrideDetails(
|
|
occurrence: Partial<CalendarEvent>,
|
|
patch: Partial<CalendarEvent>,
|
|
options: OverridePatchOptions = {},
|
|
): Partial<CalendarEvent> {
|
|
if (!createsOverride(occurrence)) return options.seriesKeys ? withOverrideOrganizer(occurrence, patch) : patch;
|
|
const names = options.seriesKeys ? [...NEW_OVERRIDE_COPIED_KEYS, ...NEW_OVERRIDE_SERIES_KEYS] : NEW_OVERRIDE_COPIED_KEYS;
|
|
const details: Record<string, unknown> = {};
|
|
for (const name of names) {
|
|
if (!(name in patch) && occurrence[name] != null) details[name] = occurrence[name];
|
|
}
|
|
return { ...details, ...patch };
|
|
}
|
|
|
|
/**
|
|
* The patch that answers one occurrence as `participantId`.
|
|
*
|
|
* - The whole participants map, with only that participant's status changed.
|
|
* Stalwart rejects a `participants/<id>/participationStatus` pointer on a
|
|
* single occurrence ("Multiple organizers found in iCalendar object",
|
|
* 0.16.23), and a pointer inside an override keeps just the patched
|
|
* participant, dropping the organizer and everyone else from it.
|
|
* - For an override on the base event (`seriesKeys`), the organizer: 0.16.19
|
|
* rejects the override the same way without it. Through a synthetic id it
|
|
* is left out - it is the series'.
|
|
* - The sequence (see NEW_OVERRIDE_COPIED_KEYS).
|
|
* - For a new override, the occurrence's details (`withNewOverrideDetails`).
|
|
*/
|
|
export function buildOccurrenceRsvpPatch(
|
|
occurrence: Partial<CalendarEvent>,
|
|
participantId: string,
|
|
status: CalendarParticipant['participationStatus'],
|
|
options: OverridePatchOptions = {},
|
|
): Partial<CalendarEvent> | null {
|
|
const participant = occurrence.participants?.[participantId];
|
|
if (!participant) return null;
|
|
const patch: Record<string, unknown> = {};
|
|
if (occurrence.sequence != null) patch.sequence = occurrence.sequence;
|
|
if (options.seriesKeys && occurrence.organizerCalendarAddress) {
|
|
patch.organizerCalendarAddress = occurrence.organizerCalendarAddress;
|
|
}
|
|
patch.participants = {
|
|
...occurrence.participants,
|
|
[participantId]: { ...participant, participationStatus: status },
|
|
};
|
|
return withNewOverrideDetails(occurrence, patch as Partial<CalendarEvent>, options);
|
|
}
|