Files
native/src/device-sync/calendar/exceptions.ts
T
Linus Rath 3ed5fa53af feat: map calendars and events to android's calendar provider #34
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.
2026-09-27 04:21:30 +02:00

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>) : {};
}