mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
The engine hands every event it syncs to a planner that turns it into CalendarContract rows, and device edits back into JMAP writes. This is that planner for calendars (`src/device-sync/calendar`), now used by the sync task: - Calendars become calendar rows with what the apps need (SYNC_EVENTS=1, an owner, colours, the reminders allowed); calendars of shared accounts carry the account's name. - Events keep their time zone, floating and all-day times, recurrence rules, excluded and changed instances, reminders, attendees and the user's own reply. A rule Android can't hold (RSCALE, SKIP, leap months) stays on the server untouched, and local times Stalwart can't resolve (a start in a repeated hour, an UNTIL in a skipped one) are written in a form it keeps. - Device edits upload as patches of what differs from the last synced state, never a whole event, and only what the device can show is ever sent. A single-instance edit or deletion becomes an override of its series; "this and following" caps the series and creates a new one. How Etar and Fossify save (a cancelled instance instead of an exclusion, attendee rows deleted and re-inserted) is read as the edit it stands for, not as a deletion. Invitations go out on the changes a calendar app would notify about. - Events in read-only calendars and subscribed feeds are put back when the device changes them; only the user's own reply goes up, where the server allows it. Tasks, task-only calendars and the birthday calendar never reach the device. - Reminders are written as Reminders rows when the calendar app is to remind the user, and left out when Bulwark does. Checked: 179 calendar tests (download, upload, exceptions, the edits Etar, Fossify and CONTENT_EXCEPTION_URI make, echoes that write nothing, time zone and DST edges), and `npm run typecheck`, `npm test` and `npm run i18n:check` on this commit.
87 lines
3.6 KiB
TypeScript
87 lines
3.6 KiB
TypeScript
/**
|
|
* Recurrence overrides and the exception rows they become
|
|
* (docs/device-sync.md, "Recurrence").
|
|
*
|
|
* An override value is a PatchObject against the master (RFC 8984 §4.3.4),
|
|
* so an exception row shows master ⊕ override, applied pointer by pointer
|
|
* (the app's own expansion shallow-merges and turns
|
|
* `participants/p1/participationStatus` into a junk key, R3 B10). Stalwart
|
|
* drops a handful of properties from every override it stores; they are
|
|
* never sent in one.
|
|
*/
|
|
import type { CalendarEventWire } from '../wire';
|
|
import { applyPatch, ptrSegments } from '../common/patch';
|
|
import { clone } from '../common/json';
|
|
import { dateToUtcMidnight, localToUtc, utcMidnightToDate, utcToLocal } from './zoned-time';
|
|
|
|
/** Properties calcard removes from an override (`remove_forbidden_override_patches`). */
|
|
export const FORBIDDEN_OVERRIDE_KEYS = [
|
|
'@type',
|
|
'method',
|
|
'organizerCalendarAddress',
|
|
'privacy',
|
|
'prodId',
|
|
'recurrenceId',
|
|
'recurrenceIdTimeZone',
|
|
'sentBy',
|
|
'uid',
|
|
'recurrenceRule',
|
|
'recurrenceOverrides',
|
|
] as const;
|
|
|
|
const FORBIDDEN = new Set<string>(FORBIDDEN_OVERRIDE_KEYS);
|
|
|
|
/** Properties of the master that never belong to an instance. */
|
|
const SERIES_ONLY = ['recurrenceRule', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRule', 'excludedRecurrenceRules'];
|
|
|
|
export type Override = Record<string, unknown>;
|
|
|
|
export function isExcluded(override: unknown): boolean {
|
|
return !!override && typeof override === 'object' && (override as Override).excluded === true;
|
|
}
|
|
|
|
/** An override without what Stalwart would drop from it (top-level and pointer keys alike). */
|
|
export function withoutForbiddenKeys(override: Override): Override {
|
|
const out: Override = {};
|
|
for (const [key, value] of Object.entries(override)) {
|
|
if (FORBIDDEN.has(ptrSegments(key)[0])) continue;
|
|
out[key] = value;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* One instance of a series: the master without its recurrence, patched with
|
|
* the override (pointer by pointer; a pointer whose parent is missing is
|
|
* skipped rather than failing the instance), starting at the recurrence id
|
|
* unless the override moves it.
|
|
*/
|
|
export function instanceOf(master: CalendarEventWire, key: string, override?: Override | null): CalendarEventWire {
|
|
let instance = clone(master) as Record<string, unknown>;
|
|
for (const name of SERIES_ONLY) delete instance[name];
|
|
instance.start = key;
|
|
for (const [pointer, value] of Object.entries(override ?? {})) {
|
|
if (pointer === 'excluded' || pointer === 'updated') continue;
|
|
if (FORBIDDEN.has(ptrSegments(pointer)[0])) continue;
|
|
const patched = applyPatch(instance, { [pointer]: value });
|
|
if (patched) instance = patched;
|
|
}
|
|
return instance as CalendarEventWire;
|
|
}
|
|
|
|
/** ORIGINAL_INSTANCE_TIME of a recurrence id: its instant in the master's zone, UTC midnight for all-day series. */
|
|
export function keyToInstanceTime(key: string, allDay: boolean, zone: string): number | null {
|
|
return allDay ? dateToUtcMidnight(key) : localToUtc(key, zone);
|
|
}
|
|
|
|
/** The recurrence id of an ORIGINAL_INSTANCE_TIME (the inverse of `keyToInstanceTime`). */
|
|
export function instanceTimeToKey(originalInstanceTime: number, allDay: boolean, zone: string): string {
|
|
return allDay ? utcMidnightToDate(originalInstanceTime) : utcToLocal(originalInstanceTime, zone);
|
|
}
|
|
|
|
/** The overrides of an event as a plain map (Stalwart omits the property when there are none). */
|
|
export function overridesOf(event: CalendarEventWire | null | undefined): Record<string, Override> {
|
|
const map = event?.recurrenceOverrides;
|
|
return map && typeof map === 'object' ? (map as Record<string, Override>) : {};
|
|
}
|