Files
native/src/device-sync/calendar/create.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

281 lines
14 KiB
TypeScript

/**
* New and deleted events (docs/device-sync.md, "Uploads" and "Identity").
*
* A new master row is claimed before anything is sent: the uid and target go
* into SYNC_DATA3, UID_2445 and `_SYNC_ID = ~pending/<uid>`, so an app delete
* becomes a soft delete the engine can act on and a retried create is found
* by uid. The row keeps a uid it carries unless another row does too (a
* CONTENT_EXCEPTION_URI split clones UID_2445). The create then sends the
* whole event, with the exceptions and exclusions made before its upload as
* overrides. A deleted event is destroyed, or loses only its synced calendar
* memberships when it is in others as well.
*/
import type { Participant } from '../../api/types';
import { AttendeeType, Attendees, Events } from '../android-columns';
import type { CalendarContext, LocalEvent, LocalException, OpGroup, UploadAction } from '../planner';
import type { Row } from '../types';
import type { CalendarEventWire } from '../wire';
import { collectionKey, parseCollectionKey, parseObjectRef, pendingRef, pendingUidOf } from '../common/ids';
import type { PatchObject } from '../common/patch';
import { isSelfAddress } from './attendees';
import { applyAttendeeEdits, attendeeEdits, descriptionValue, locationChanges, reminderChanges, titleValue } from './edits';
import { exdateEntryKey } from './exdate';
import type { Override } from './exceptions';
import { isRemovedInstance } from './merge';
import { rruleParts, type RuleZone } from './rrule';
import { sendsSchedulingMessages } from './scheduling';
import { fixedOffsetTiming, rowTiming } from './timing';
import { exdateEntries, sideOfRow } from './units';
import { SKIP, SkipUpload, deviceRule, newOverride, put } from './upload';
import { argbToCss, availabilityFromDevice, participationFromDevice, privacyFromDevice, statusFromDevice } from './values';
import { canonicalZone, classifyLocal } from './zoned-time';
// ─── Creates ────────────────────────────────────────────
export interface CreateComputation {
object: Partial<CalendarEventWire>;
sendSchedulingMessages: boolean;
}
/** The event a new master row (with its exceptions and EXDATE) creates. Throws `SkipUpload`. */
export function computeCreate(local: LocalEvent, uid: string, calendarId: string, ctx: CalendarContext): CreateComputation {
const cells = local.cells;
const recurring = !!rruleParts(cells[Events.RRULE]);
let t = rowTiming(cells, { server: null, baselineZone: null, recurring, deviceZone: ctx.deviceZone });
if (!t) throw new SkipUpload('noStart');
if (t.ambiguous) {
if (recurring) throw new SkipUpload(SKIP.dstAmbiguous);
t = fixedOffsetTiming(t);
}
const object: Record<string, unknown> = {
'@type': 'Event',
uid,
calendarIds: { [calendarId]: true },
title: titleValue(cells),
start: t.start,
duration: t.duration,
status: statusFromDevice(cells[Events.STATUS]),
freeBusyStatus: availabilityFromDevice(cells[Events.AVAILABILITY]),
};
if (t.showWithoutTime) object.showWithoutTime = true;
else object.timeZone = t.timeZone;
const description = descriptionValue(cells);
if (description) object.description = description;
const privacy = privacyFromDevice(cells[Events.ACCESS_LEVEL]);
if (privacy) object.privacy = privacy;
const color = argbToCss(cells[Events.EVENT_COLOR]);
if (color) object.color = color;
const location = locationChanges({}, cells[Events.EVENT_LOCATION], ctx.mintKey).locations;
if (location) object.locations = location;
const { participants, organizer } = createParticipants(local, ctx);
if (participants) {
object.participants = participants;
object.organizerCalendarAddress = `mailto:${organizer}`;
}
if (ctx.reminderOwner === 'device' && local.reminders.length) {
const alerts = reminderChanges({}, undefined, local.reminders.map((r) => r.cells), ctx.mintKey).alerts;
if (Object.keys(alerts).length) object.alerts = alerts;
}
const zone: RuleZone = t.showWithoutTime ? { allDay: true, zone: 'UTC' } : { allDay: false, zone: canonicalZone(t.timeZone) ?? 'UTC' };
const rule = recurring ? deviceRule(cells[Events.RRULE], null, null, zone, false) : null;
if (rule) {
object.recurrenceRule = rule;
const overrides: Record<string, Override> = {};
const checked = (key: string | null) => {
if (key && !zone.allDay && classifyLocal(key, zone.zone) === 'overlap') throw new SkipUpload(SKIP.dstAmbiguous);
return key;
};
for (const entry of exdateEntries(sideOfRow(local))) {
const key = checked(exdateEntryKey(entry, zone));
if (key) overrides[key] = { excluded: true };
}
for (const x of local.exceptions) {
const key = checked(x.recurrenceId);
if (!key) continue;
overrides[key] = isRemovedInstance(x) ? { excluded: true } : createOverride(x, object as CalendarEventWire, key, local, ctx);
}
if (Object.keys(overrides).length) object.recurrenceOverrides = overrides;
}
return {
object: object as Partial<CalendarEventWire>,
sendSchedulingMessages: sendsSchedulingMessages(object as CalendarEventWire, { kind: 'create' }, ctx),
};
}
/** An exception of a series that is created with it: what the exception row says differently from its master row. */
function createOverride(x: LocalException, master: CalendarEventWire, key: string, local: LocalEvent, ctx: CalendarContext): Override {
const values: Record<string, unknown> = {};
let t = rowTiming(x.cells, {
server: master,
baselineZone: String(local.cells[Events.EVENT_TIMEZONE] ?? ''),
recurring: false,
deviceZone: ctx.deviceZone,
});
if (t) {
if (t.ambiguous) throw new SkipUpload(SKIP.dstAmbiguous);
if (t.showWithoutTime !== !!master.showWithoutTime) t = { ...t, showWithoutTime: !!master.showWithoutTime };
values.start = t.start;
values.duration = t.duration;
}
const cells = x.cells;
// What the exception row leaves empty is one its app did not model: it comes from the series.
const differs = (column: string) => {
const v = cells[column];
return v !== null && v !== undefined && v !== '' && String(v) !== String(local.cells[column] ?? '');
};
if (differs(Events.TITLE)) values.title = titleValue(cells);
if (differs(Events.DESCRIPTION)) values.description = descriptionValue(cells);
if (differs(Events.EVENT_LOCATION)) values.locations = locationChanges(master, cells[Events.EVENT_LOCATION], ctx.mintKey).locations;
if (differs(Events.STATUS)) values.status = statusFromDevice(cells[Events.STATUS]);
if (differs(Events.AVAILABILITY)) values.freeBusyStatus = availabilityFromDevice(cells[Events.AVAILABILITY]);
if (differs(Events.EVENT_COLOR)) values.color = argbToCss(cells[Events.EVENT_COLOR]);
if (x.attendees.length) {
const edits = attendeeEdits(x.attendees.map((a) => a.cells), local.attendees.map((a) => a.cells), ctx, false);
if (edits.length) values.participants = applyAttendeeEdits(master.participants, edits, ctx, ctx.mintKey, false).participants;
}
return newOverride(master, key, values);
}
/**
* Participants of a new event from its attendee rows: the user as organizer
* (unless ORGANIZER names someone else) and everyone else as attendees.
* None when the user is alone.
*/
function createParticipants(local: LocalEvent, ctx: CalendarContext): { participants: Record<string, Participant> | null; organizer: string } {
const rows = local.attendees.map((a) => a.cells);
const isSelf = (row: Row) => isSelfAddress(String(row[Attendees.ATTENDEE_EMAIL] ?? ''), ctx);
const organizerCell = String(local.cells[Events.ORGANIZER] ?? '').trim();
const organizer = organizerCell && !isSelfAddress(organizerCell, ctx) ? organizerCell : ctx.ownerAccount;
const guests = rows.filter((r) => !isSelf(r));
if (!guests.length) return { participants: null, organizer };
const participants: Record<string, Participant> = {};
const self = rows.find(isSelf);
const iOrganize = isSelfAddress(organizer, ctx);
if (iOrganize || self) {
const status = self ? participationFromDevice(self[Attendees.ATTENDEE_STATUS]) : null;
participants[ctx.mintKey(Object.keys(participants))] = {
'@type': 'Participant',
calendarAddress: `mailto:${ctx.ownerAccount}`,
roles: iOrganize ? { owner: true, attendee: true } : { attendee: true },
participationStatus: status ?? (iOrganize ? 'accepted' : 'needs-action'),
};
}
for (const row of guests) {
const email = String(row[Attendees.ATTENDEE_EMAIL]).trim();
const p: Participant = {
'@type': 'Participant',
calendarAddress: `mailto:${email}`,
roles: email.toLowerCase() === organizer.toLowerCase() ? { owner: true } : { attendee: true },
participationStatus: 'needs-action',
expectReply: true,
};
const name = row[Attendees.ATTENDEE_NAME];
if (typeof name === 'string' && name.trim()) p.name = name.trim();
if (Number(row[Attendees.ATTENDEE_TYPE]) === AttendeeType.OPTIONAL) p.roles = { ...p.roles, optional: true };
if (Number(row[Attendees.ATTENDEE_TYPE]) === AttendeeType.RESOURCE) p.kind = 'resource';
participants[ctx.mintKey(Object.keys(participants))] = p;
}
return { participants, organizer };
}
// ─── Claims ─────────────────────────────────────────────
/**
* The claim of a new row (see the header). A split clone also drops the
* source's shadow and baseline it was cloned with, points the source's
* exceptions back at the source (the provider's trigger may have followed the
* `_SYNC_ID` change) and marks the source dirty, so the source's capped rule
* uploads even when this group is applied before the source is planned.
*/
export function claimOps(local: LocalEvent, uid: string, target: string): OpGroup {
const ops: OpGroup['ops'] = [
{
op: 'assert',
table: 'events',
id: local.eventId,
values: { [Events._SYNC_ID]: local.syncId, [Events.DELETED]: local.cells[Events.DELETED] ?? null },
expectCount: 1,
},
];
const values: Row = {
[Events._SYNC_ID]: pendingRef(uid),
[Events.UID_2445]: uid,
[Events.SYNC_DATA3]: JSON.stringify({ uid, target }),
};
const sharedSyncId = local.split === 'clone' && parseObjectRef(local.syncId) ? local.syncId : null;
if (local.split === 'clone') {
Object.assign(values, { [Events.SYNC_DATA1]: null, [Events.SYNC_DATA2]: null, [Events.SYNC_DATA4]: null, [Events.SYNC_DATA5]: null });
}
ops.push({ op: 'update', table: 'events', id: local.eventId, values, expectCount: 1 });
if (sharedSyncId) {
ops.push({
op: 'update',
table: 'events',
where: `${Events.ORIGINAL_SYNC_ID} = ? AND (${Events.ORIGINAL_ID} IS NULL OR ${Events.ORIGINAL_ID} != ?)`,
args: [pendingRef(uid), local.eventId],
values: { [Events.ORIGINAL_SYNC_ID]: sharedSyncId },
});
// The clone no longer carries the shared `_SYNC_ID` here, so this reaches the source master(s) only.
ops.push({
op: 'update',
table: 'events',
where: `${Events._SYNC_ID} = ? AND ${Events.ORIGINAL_ID} IS NULL`,
args: [sharedSyncId],
values: { [Events.DIRTY]: 1 },
});
}
return { ref: `row:${local.eventId}`, ops };
}
/** The uid of a new row's complete, consistent claim, or null when it has to be claimed (again). */
export function claimedUid(local: LocalEvent): string | null {
if (local.split === 'clone' || !local.pending) return null;
const uid = local.pending.uid;
if (pendingUidOf(local.syncId) !== uid || local.cells[Events.UID_2445] !== uid) return null;
return uid;
}
/** The uid a claim writes: the row's own unless it is a clone of another row's. */
export function claimUid(local: LocalEvent, ctx: CalendarContext): string {
if (local.split === 'clone') return ctx.mintUid();
const own = local.cells[Events.UID_2445];
if (typeof own === 'string' && own.trim()) return own;
return pendingUidOf(local.syncId) ?? ctx.mintUid();
}
/** Where a new row's create goes: the target fixed at its claim, else the calendar of its row. */
export function createTarget(local: LocalEvent, ctx: CalendarContext): { key: string; calendarId: string } | null {
if (local.pending && local.split !== 'clone') {
const parsed = parseCollectionKey(local.pending.target);
if (parsed) return { key: local.pending.target, calendarId: parsed.id };
}
const cal = ctx.calendarIdOfRow(local.calendarRowId);
return cal ? { key: collectionKey(cal.jmapAccountId, cal.calendarId), calendarId: cal.calendarId } : null;
}
// ─── Deletions ──────────────────────────────────────────
/**
* A deleted event with identity: an object in several calendars loses only
* the selected memberships; otherwise it is destroyed, with scheduling
* messages when others take part.
*/
export function deleteActions(local: LocalEvent, ctx: CalendarContext): UploadAction<CalendarEventWire>[] {
const ref = parseObjectRef(local.syncId)!;
const shadow = local.shadow;
if (shadow) {
const memberships = Object.entries(shadow.calendarIds ?? {}).filter(([, on]) => on).map(([id]) => id);
const selected = memberships.filter((id) => ctx.isSelected(collectionKey(ctx.jmapAccountId, id)));
if (selected.length && selected.length < memberships.length) {
const patch: PatchObject = {};
for (const id of selected) put(patch, ['calendarIds', id], null);
return [{ kind: 'update', id: ref.id, patch }];
}
}
const action: UploadAction<CalendarEventWire> = { kind: 'destroy', id: ref.id, uid: typeof shadow?.uid === 'string' ? shadow.uid : null };
if (shadow && sendsSchedulingMessages(shadow, { kind: 'destroy' }, ctx)) action.sendSchedulingMessages = true;
return [action];
}