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

91 lines
3.5 KiB
TypeScript

/**
* After the server accepted an upload (docs/device-sync.md, "Uploads",
* "Afterwards"): when the rows were edited again while the upload was under
* way, the clearing group's assert fails and `keepDirtyOps` apply instead.
* They write the identity and the new shadow, and set the baselines of the
* uploaded units to the values that were uploaded (the rows as read before
* the upload), leaving the rows and DIRTY alone: the newer edit, or a revert
* to the old value, uploads next time.
*/
import { Events } from '../android-columns';
import type { CalendarContext, EventBaseline, LocalEvent, LocalEventRow, OpGroup } from '../planner';
import type { CalendarEventWire } from '../wire';
import { exceptionRef, objectRef } from '../common/ids';
import { clone } from '../common/json';
import { EXCEPTION_CELLS, MASTER_CELLS, encodeBaseline, makeBaseline } from './columns';
import { COLUMN_UNITS } from './units';
export interface UploadedUnits {
/** Everything was uploaded (a create). */
all: boolean;
master: Set<string>;
/** By exception row id. */
exceptions: Map<number, Set<string>>;
/** Exception rows whose instance is excluded on the server now. */
removed: Set<number>;
}
function unitColumns(unit: string): readonly string[] {
return COLUMN_UNITS[unit] ?? (unit.startsWith('exdate:') ? [Events.EXDATE] : []);
}
function uploadedBaseline(row: LocalEventRow, isException: boolean, units: Set<string> | null, all: boolean): EventBaseline {
const uploaded = makeBaseline(
row.cells,
isException ? EXCEPTION_CELLS : MASTER_CELLS,
row.attendees.map((a) => a.cells),
row.reminders.map((r) => r.cells),
);
if (all || !row.baseline) return uploaded;
const next = clone(row.baseline);
for (const unit of units ?? []) {
for (const c of unitColumns(unit)) next.cells[c] = uploaded.cells[c] ?? null;
if (unit === 'reminders') next.reminders = uploaded.reminders;
if (unit.startsWith('attendee')) next.attendees = uploaded.attendees;
}
return next;
}
export function keepDirtyGroup(local: LocalEvent, server: CalendarEventWire, uploaded: UploadedUnits, ctx: CalendarContext): OpGroup {
const masterRef = objectRef(ctx.jmapAccountId, server.id);
const ops: OpGroup['ops'] = [
{
op: 'update',
table: 'events',
id: local.eventId,
values: {
[Events._SYNC_ID]: masterRef,
[Events.UID_2445]: typeof server.uid === 'string' ? server.uid : null,
[Events.SYNC_DATA1]: JSON.stringify(server),
[Events.SYNC_DATA3]: null,
[Events.SYNC_DATA4]: encodeBaseline(uploadedBaseline(local, false, uploaded.master, uploaded.all)),
[Events.SYNC_DATA5]: null,
},
expectCount: 1,
},
];
for (const x of local.exceptions) {
// An instance deleted on the device is an exclusion on the server now: its row has done its job.
if (uploaded.removed.has(x.eventId)) {
ops.push({ op: 'delete', table: 'events', id: x.eventId, expectCount: 1 });
continue;
}
const units = uploaded.exceptions.get(x.eventId);
if ((!uploaded.all && !units) || !x.recurrenceId) continue;
const all = uploaded.all || !!units?.has('new');
ops.push({
op: 'update',
table: 'events',
id: x.eventId,
values: {
[Events._SYNC_ID]: exceptionRef(masterRef, x.recurrenceId),
[Events.SYNC_DATA2]: x.recurrenceId,
[Events.ORIGINAL_SYNC_ID]: masterRef,
[Events.SYNC_DATA4]: encodeBaseline(uploadedBaseline(x, true, units ?? null, all)),
},
expectCount: 1,
});
}
return { ref: masterRef, ops };
}