mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-06 08:07:23 +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.
91 lines
3.5 KiB
TypeScript
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 };
|
|
}
|