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.
This commit is contained in:
Linus Rath
2026-09-27 04:21:30 +02:00
parent 96a6b38321
commit 3ed5fa53af
38 changed files with 7339 additions and 5 deletions
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import { CalendarAccess, Calendars } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { CAN_PARTIALLY_UPDATE } from '../../calendar/columns';
import { cssColorToArgb } from '../../calendar/values';
import { BIRTHDAY_CALENDAR_ID } from '../../../lib/birthday-calendar';
import type { CalendarLike } from '../../wire';
import { ACCT, CALENDARS, Harness, ME } from './harness';
const personal = (calendar: CalendarLike, readOnly = false) => ({ jmapAccountId: ACCT, calendar, readOnly, accountName: ME });
describe('calendar planner: calendar rows', () => {
it('inserts every column the provider and the calendar apps need', async () => {
const h = await new Harness().setup(['b']);
const [row] = await h.calendars();
expect(row.syncId).toBe(`${ACCT}/b`);
expect(row.cells).toMatchObject({
[Calendars.NAME]: 'Personal',
[Calendars.CALENDAR_DISPLAY_NAME]: 'Personal',
[Calendars.CALENDAR_COLOR]: cssColorToArgb('#3b82f6'),
[Calendars.CALENDAR_ACCESS_LEVEL]: CalendarAccess.OWNER,
[Calendars.OWNER_ACCOUNT]: ME,
[Calendars.SYNC_EVENTS]: 1,
[Calendars.VISIBLE]: 1,
[Calendars.CALENDAR_TIME_ZONE]: 'Europe/Berlin',
[Calendars.ALLOWED_REMINDERS]: '1,2',
[Calendars.ALLOWED_AVAILABILITY]: '0,1',
[Calendars.ALLOWED_ATTENDEE_TYPES]: '0,1,2,3',
[Calendars.MAX_REMINDERS]: 5,
[Calendars.CAN_ORGANIZER_RESPOND]: 0,
[CAN_PARTIALLY_UPDATE]: 0,
});
// CAL_SYNC1 would become the `feed` extra of every sync the provider requests for the calendar.
expect(h.fake.row('calendars', row.calendarRowId)).toMatchObject({ [Calendars.CAL_SYNC1]: null, [Calendars.DIRTY]: 0 });
expect(row.shadow).toEqual(CALENDARS.b);
expect(row.flags).toEqual({});
});
it('writes nothing when nothing changed, and only what changed otherwise', async () => {
const h = await new Harness().setup(['b']);
const local = await h.calendars();
expect(calendarPlanner.planCalendars([personal(CALENDARS.b)], local, h.ctx)).toEqual({ groups: [], deleteCalendarRows: [] });
const renamed = { ...CALENDARS.b, name: 'Private', color: 'tomato' } as CalendarLike;
const plan = calendarPlanner.planCalendars([personal(renamed)], local, h.ctx);
expect(plan.groups).toHaveLength(1);
const [op] = plan.groups[0].ops;
expect(op).toMatchObject({ op: 'update', table: 'calendars', id: local[0].calendarRowId, expectCount: 1 });
expect(Object.keys((op as { values: object }).values).sort()).toEqual(
[Calendars.NAME, Calendars.CALENDAR_DISPLAY_NAME, Calendars.CALENDAR_COLOR, Calendars.CAL_SYNC2].sort(),
);
// VISIBLE is the user's once the row exists.
expect((op as { values: object }).values).not.toHaveProperty(Calendars.VISIBLE);
});
it('keeps VISIBLE as the user set it', async () => {
const h = await new Harness().setup(['b']);
const [row] = await h.calendars();
await h.apply({ ref: 'x', ops: [{ op: 'update', table: 'calendars', id: row.calendarRowId, values: { [Calendars.VISIBLE]: 0 } }] });
expect(calendarPlanner.planCalendars([personal(CALENDARS.b)], await h.calendars(), h.ctx).groups).toEqual([]);
});
it('maps rights to access levels, and a writable read-only calendar is a subscribed feed', () => {
const level = (rights: CalendarLike['myRights'], readOnly = false) => {
const plan = calendarPlanner.planCalendars([personal({ id: 'z', name: 'Z', myRights: rights } as CalendarLike, readOnly)], [], new Harness().ctx);
const values = (plan.groups[0].ops[0] as { values: Record<string, unknown> }).values;
return [values[Calendars.CALENDAR_ACCESS_LEVEL], JSON.parse(String(values[Calendars.CAL_SYNC3]))];
};
expect(level({ mayWriteAll: true, mayWriteOwn: true })).toEqual([CalendarAccess.OWNER, {}]);
expect(level({ mayWriteOwn: true })).toEqual([CalendarAccess.CONTRIBUTOR, {}]);
expect(level({ mayRSVP: true }, true)).toEqual([CalendarAccess.RESPOND, { readOnly: 'rights' }]);
expect(level({ mayReadItems: true }, true)).toEqual([CalendarAccess.READ, { readOnly: 'rights' }]);
expect(level({ mayWriteAll: true }, true)).toEqual([CalendarAccess.READ, { readOnly: 'subscription' }]);
});
it('offers no reminder editor when Bulwark owns reminders', async () => {
const h = await new Harness({ reminderOwner: 'bulwark' }).setup(['b']);
expect((await h.calendars())[0].cells[Calendars.MAX_REMINDERS]).toBe(0);
h.ctx = { ...h.ctx, reminderOwner: 'device' };
const plan = calendarPlanner.planCalendars([personal(CALENDARS.b)], await h.calendars(), h.ctx);
expect((plan.groups[0].ops[0] as { values: object }).values).toEqual({ [Calendars.MAX_REMINDERS]: 5 });
});
it('names shared accounts\' calendars after their account and colours calendars without a colour', () => {
const ctx = new Harness().ctx;
const plan = calendarPlanner.planCalendars(
[{ jmapAccountId: 'd', calendar: { id: 'x', name: 'Team', myRights: { mayWriteAll: true } } as CalendarLike, readOnly: false, accountName: 'Bob Builder' }],
[],
ctx,
);
const values = (plan.groups[0].ops[0] as { values: Record<string, unknown> }).values;
expect(values[Calendars._SYNC_ID]).toBe('d/x');
expect(values[Calendars.CALENDAR_DISPLAY_NAME]).toBe('Team (Bob Builder)');
expect(typeof values[Calendars.CALENDAR_COLOR]).toBe('number');
expect(values[Calendars.OWNER_ACCOUNT]).toBe(ME);
});
it('deletes rows of deselected calendars and never syncs the birthday calendar', async () => {
const h = await new Harness().setup(['b', 'w']);
const local = await h.calendars();
const plan = calendarPlanner.planCalendars(
[personal(CALENDARS.b), personal({ id: BIRTHDAY_CALENDAR_ID, name: 'Birthdays' } as CalendarLike)],
local,
h.ctx,
);
expect(plan.groups).toEqual([]);
expect(plan.deleteCalendarRows).toEqual([local.find((c) => c.syncId === `${ACCT}/w`)!.calendarRowId]);
});
it('reads calendar rows defensively', () => {
const cal = calendarPlanner.decodeCalendar({ _id: '4', _sync_id: 'c/b', cal_sync2: 'garbage', cal_sync3: '{"readOnly":"subscription","taskOnly":true}' });
expect(cal).toMatchObject({ calendarRowId: 4, syncId: 'c/b', shadow: null, flags: { readOnly: 'subscription', taskOnly: true } });
expect(calendarPlanner.decodeCalendar({ _id: 5, _sync_id: null, cal_sync3: '[1]' }).flags).toEqual({});
});
});
@@ -0,0 +1,248 @@
import { describe, expect, it } from 'vitest';
import { Attendees, Calendars, Events, Reminders } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { cssColorToArgb } from '../../calendar/values';
import { ACCT, CALENDARS, Harness, ME, single, utc, weekly } from './harness';
import type { CalendarEventWire } from '../../wire';
describe('calendar planner: downloads', () => {
it('writes a recurring event with its exception, exclusion, attendees and reminders', async () => {
const h = await new Harness().setup();
const plan = await h.download(weekly());
expect(plan.effect).toBe('insert');
const master = h.masterRow('bl');
expect(master).toMatchObject({
[Events.TITLE]: 'Probe weekly',
[Events.DESCRIPTION]: 'desc',
[Events.EVENT_LOCATION]: 'Room 1',
[Events.STATUS]: 0,
[Events.AVAILABILITY]: 1,
[Events.ACCESS_LEVEL]: 2,
[Events.EVENT_COLOR]: cssColorToArgb('steelblue'),
[Events.DTSTART]: utc('2026-10-05T13:00:00Z'),
[Events.DTEND]: null,
[Events.DURATION]: 'P5400S',
[Events.EVENT_TIMEZONE]: 'America/New_York',
[Events.ALL_DAY]: 0,
[Events.RRULE]: 'FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE',
[Events.EXDATE]: '20261012T130000Z',
[Events.RDATE]: null,
[Events.UID_2445]: 'probe-rec@example.org',
[Events.ORGANIZER]: ME,
[Events.HAS_ATTENDEE_DATA]: 1,
[Events.DIRTY]: 0,
});
expect(JSON.parse(String(master[Events.SYNC_DATA1]))).toEqual(weekly());
expect(master[Events.SELF_ATTENDEE_STATUS] ?? null).toBeNull();
const exceptions = h.fake.rows('events', `${Events.ORIGINAL_SYNC_ID} = ?`, [`${ACCT}/bl`]);
expect(exceptions).toHaveLength(1);
expect(exceptions[0]).toMatchObject({
[Events._SYNC_ID]: `${ACCT}/bl#2026-10-07T09:00:00`,
[Events.SYNC_DATA2]: '2026-10-07T09:00:00',
[Events.ORIGINAL_ID]: master._id,
// Exact to the millisecond, or the provider shows the original instance as well.
[Events.ORIGINAL_INSTANCE_TIME]: utc('2026-10-07T13:00:00Z'),
[Events.ORIGINAL_ALL_DAY]: 0,
[Events.TITLE]: 'Moved Wednesday',
[Events.DTSTART]: utc('2026-10-07T15:00:00Z'),
[Events.DTEND]: utc('2026-10-07T16:30:00Z'),
[Events.DURATION]: null,
[Events.RRULE]: null,
[Events.STATUS]: 0,
});
const attendees = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [Number(master._id)]);
expect(attendees.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_RELATIONSHIP], a[Attendees.ATTENDEE_STATUS]])).toEqual([
[ME, 2, 1],
['guest@example.net', 1, 3],
]);
const reminders = h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)]);
// Only the start-relative offset alert has a Reminders row; the others stay on the server.
expect(reminders.map((r) => [r[Reminders.MINUTES], r[Reminders.METHOD]])).toEqual([[15, 1]]);
});
it('writes nothing for an echo of what the rows already hold', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const batches = h.fake.batches.length;
const plan = calendarPlanner.planDownload(weekly(), await h.local('bl'), h.ctx);
expect(plan).toMatchObject({ effect: 'none', writes: 0, conflicts: 0, stillDirty: false });
expect(plan.ops.ops).toEqual([]);
expect(h.fake.batches.length).toBe(batches);
});
it('writes only what the server changed, behind asserts', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const plan = await h.download({ ...weekly(), title: 'Renamed', updated: '2026-09-27T08:00:00Z' });
expect(plan.effect).toBe('update');
const ops = plan.ops.ops;
const firstWrite = ops.findIndex((op) => op.op !== 'assert');
expect(ops.slice(0, firstWrite).every((op) => op.op === 'assert')).toBe(true);
expect(ops.slice(firstWrite).every((op) => op.op !== 'assert')).toBe(true);
const updates = ops.filter((op) => op.op === 'update');
expect(updates).toHaveLength(1);
expect(Object.keys((updates[0] as { values: object }).values).sort()).toEqual([Events.SYNC_DATA1, Events.SYNC_DATA4, Events.TITLE].sort());
expect(h.masterRow('bl')[Events.TITLE]).toBe('Renamed');
});
it('updates only the shadow when the server changed a property the device does not show', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const plan = await h.download({ ...weekly(), priority: 1, keywords: { other: true } });
expect(plan).toMatchObject({ effect: 'update', writes: 1 });
expect(JSON.parse(String(h.masterRow('bl')[Events.SYNC_DATA1])).priority).toBe(1);
});
it('keeps floating events on the wall clock of the device zone', async () => {
const h = await new Harness({ deviceZone: 'America/New_York' }).setup();
await h.download(single({ id: 'f1', timeZone: null, start: '2026-10-06T14:00:00', duration: 'PT45M' }));
expect(h.masterRow('f1')).toMatchObject({
[Events.EVENT_TIMEZONE]: 'America/New_York',
[Events.DTSTART]: utc('2026-10-06T18:00:00Z'),
[Events.DTEND]: utc('2026-10-06T18:45:00Z'),
});
});
it('writes a timed event in its own zone, not the device one', async () => {
const h = await new Harness({ deviceZone: 'Europe/Berlin' }).setup();
await h.download(single({ id: 'tk', timeZone: 'Asia/Tokyo', start: '2026-10-06T09:00:00' }));
expect(h.masterRow('tk')).toMatchObject({ [Events.EVENT_TIMEZONE]: 'Asia/Tokyo', [Events.DTSTART]: utc('2026-10-06T00:00:00Z') });
});
it('writes all-day events as UTC midnights, multi-day and in weeks too', async () => {
const h = await new Harness({ deviceZone: 'Pacific/Auckland' }).setup();
await h.download(single({ id: 'ad', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P2D' }));
expect(h.masterRow('ad')).toMatchObject({
[Events.ALL_DAY]: 1,
[Events.EVENT_TIMEZONE]: 'UTC',
[Events.DTSTART]: utc('2026-10-08T00:00:00Z'),
[Events.DTEND]: utc('2026-10-10T00:00:00Z'),
});
// CalDAV DTENDs arrive as weeks.
await h.download(single({ id: 'wk', start: '2026-10-12T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1W' }));
expect(h.masterRow('wk')[Events.DTEND]).toBe(utc('2026-10-19T00:00:00Z'));
// A recurring all-day event gets a DURATION in days (the provider throws on seconds).
await h.download(single({ id: 'ar', start: '2026-10-12T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D', recurrenceRule: { frequency: 'yearly' } }));
expect(h.masterRow('ar')).toMatchObject({ [Events.DURATION]: 'P1D', [Events.DTEND]: null, [Events.RRULE]: 'FREQ=YEARLY' });
// A time part on an all-day duration rounds up.
await h.download(single({ id: 'ap', start: '2026-10-12T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1DT2H' }));
expect(h.masterRow('ap')[Events.DTEND]).toBe(utc('2026-10-14T00:00:00Z'));
});
it('writes the calendar defaults as reminders when the event uses them', async () => {
const h = await new Harness().setup();
await h.download(single({ id: 'da', useDefaultAlerts: true, alerts: { own: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } } }));
const master = h.masterRow('da');
const reminders = h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)]);
expect(reminders.map((r) => r[Reminders.MINUTES])).toEqual([10]);
});
it('writes no reminders when Bulwark owns them, and leaves the ones an app adds', async () => {
const h = await new Harness({ reminderOwner: 'bulwark' }).setup();
const cal = (await h.calendars()).find((c) => c.syncId === `${ACCT}/b`)!;
expect(cal.cells[Calendars.MAX_REMINDERS]).toBe(0);
await h.download(weekly());
const master = h.masterRow('bl');
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)])).toHaveLength(0);
h.fake.user.setReminders(Number(master._id), [{ minutes: 5 }]);
// Neither uploaded nor deleted: the edit only clears DIRTY.
const upload = calendarPlanner.planUpload((await h.local('bl'))!, h.ctx);
expect(upload.kind).toBe('clean');
if (upload.kind === 'clean') await h.apply(upload.ops);
await h.download({ ...weekly(), title: 'Again' });
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)])).toHaveLength(1);
});
it('normalises the user\'s own address, under any alias, to OWNER_ACCOUNT', async () => {
const h = await new Harness().setup();
const event = single({
id: 'inv',
organizerCalendarAddress: 'mailto:boss@example.net',
participants: {
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
me: { calendarAddress: 'mailto:ALIAS@Example.org', roles: { attendee: true }, participationStatus: 'tentative' },
room: { calendarAddress: 'mailto:room@example.net', kind: 'resource', roles: { attendee: true } },
opt: { calendarAddress: 'mailto:opt@example.net', roles: { attendee: true, optional: true }, participationStatus: 'declined' },
noMail: { name: 'Phone only', roles: { attendee: true } },
},
} as Partial<CalendarEventWire>);
await h.download(event);
const master = h.masterRow('inv');
expect(master[Events.ORGANIZER]).toBe('boss@example.net');
const rows = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [Number(master._id)]);
expect(rows.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_RELATIONSHIP], a[Attendees.ATTENDEE_TYPE], a[Attendees.ATTENDEE_STATUS]])).toEqual([
['boss@example.net', 2, 1, 3],
[ME, 1, 1, 4],
['room@example.net', 1, 3, 3],
['opt@example.net', 1, 2, 2],
]);
});
it('leaves ORGANIZER to the provider for events without participants', async () => {
const h = await new Harness().setup();
const plan = await h.download(single());
const insert = plan.ops.ops.find((op) => op.op === 'insert' && op.table === 'events') as { values: Record<string, unknown> };
expect(Events.ORGANIZER in insert.values).toBe(false);
expect(Events.SELF_ATTENDEE_STATUS in insert.values).toBe(false);
expect(h.masterRow('e1')[Events.ORGANIZER]).toBe(ME);
// The baseline predicted the provider's value, so there is nothing to heal and an echo writes nothing.
expect(calendarPlanner.planBaselineHeal((await h.local('e1'))!)).toBeNull();
expect(calendarPlanner.planDownload(single(), await h.local('e1'), h.ctx).effect).toBe('none');
});
it('never writes a NULL STATUS', async () => {
const h = await new Harness().setup();
await h.download(single({ status: undefined }));
expect(h.masterRow('e1')[Events.STATUS]).toBe(1);
await h.download(single({ status: 'cancelled' }));
expect(h.masterRow('e1')[Events.STATUS]).toBe(2);
});
it('removes and adds exception rows as overrides come and go', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const next = weekly();
next.recurrenceOverrides = {
'2026-10-12T09:00:00': { excluded: true },
'2026-10-14T09:00:00': { excluded: true },
'2026-10-19T09:00:00': { title: 'Other' },
};
const plan = await h.download(next);
expect(plan.effect).toBe('update');
const exceptions = h.fake.rows('events', `${Events.ORIGINAL_SYNC_ID} = ?`, [`${ACCT}/bl`]);
expect(exceptions.map((x) => x[Events.SYNC_DATA2])).toEqual(['2026-10-19T09:00:00']);
expect(exceptions[0][Events.TITLE]).toBe('Other');
expect(h.masterRow('bl')[Events.EXDATE]).toBe('20261012T130000Z,20261014T130000Z');
});
it('replaces the rows of a different object under a reused id', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const other = single({ id: 'bl', uid: 'other-uid', title: 'Someone else' });
const plan = await h.download(other);
expect(plan.effect).toBe('update');
const events = await h.events();
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ syncId: `${ACCT}/bl`, exceptions: [] });
expect(events[0].cells[Events.TITLE]).toBe('Someone else');
});
it('refuses tasks', async () => {
const h = await new Harness().setup();
expect(() => calendarPlanner.planDownload({ ...single(), '@type': 'Task' } as CalendarEventWire, null, h.ctx)).toThrow(/tasks/);
expect(() => calendarPlanner.planDownload({ ...single(), '@type': undefined, due: '2026-10-01T00:00:00' } as unknown as CalendarEventWire, null, h.ctx)).toThrow(/tasks/);
});
it('places an event in several calendars in the first selected one, and keeps it where it is', async () => {
const h = await new Harness().setup();
await h.download(single({ calendarIds: { w: true, b: true } }));
expect(h.masterRow('e1')[Events.CALENDAR_ID]).toBe(h.rowIds.get('b'));
await h.download(single({ calendarIds: { w: true } }));
expect(h.masterRow('e1')[Events.CALENDAR_ID]).toBe(h.rowIds.get('w'));
await h.download(single({ calendarIds: { w: true, b: true }, title: 'x' }));
expect(h.masterRow('e1')[Events.CALENDAR_ID]).toBe(h.rowIds.get('w'));
expect(CALENDARS.w.name).toBe('Work');
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import {
addDuration,
durationBetween,
durationTextSeconds,
formatAospDuration,
formatJsDuration,
formatJsDurationSeconds,
parseDuration,
sameDurationLength,
} from '../../calendar/duration';
import { aospDurationValid } from '../fakes/fake-provider';
import { localToUtc } from '../../calendar/zoned-time';
describe('calendar/duration', () => {
it('parses JSCalendar, iCalendar and AOSP durations', () => {
expect(parseDuration('PT1H30M')).toEqual({ negative: false, days: 0, seconds: 5400 });
expect(parseDuration('P1W')).toEqual({ negative: false, days: 7, seconds: 0 });
expect(parseDuration('P1W2D')).toEqual({ negative: false, days: 9, seconds: 0 });
expect(parseDuration('P1DT2H')).toEqual({ negative: false, days: 1, seconds: 7200 });
expect(parseDuration('-PT15M')).toEqual({ negative: true, days: 0, seconds: 900 });
expect(parseDuration('+PT15M')).toEqual({ negative: false, days: 0, seconds: 900 });
expect(parseDuration('PT1.5S')).toEqual({ negative: false, days: 0, seconds: 1.5 });
expect(parseDuration('pt10m')).toEqual({ negative: false, days: 0, seconds: 600 });
// AOSP: no T before seconds, M always minutes, Fossify's padded form.
expect(parseDuration('P3600S')).toEqual({ negative: false, days: 0, seconds: 3600 });
expect(parseDuration('P0DT1H30M0S')).toEqual({ negative: false, days: 0, seconds: 5400 });
for (const bad of ['P', 'PT', 'P1Y', 'P1.5D', '1H', '', 'PTH']) expect(parseDuration(bad)).toBeNull();
expect(durationTextSeconds('P1W')).toBe(604_800);
});
it('writes the strict JSCalendar grammar', () => {
expect(formatJsDuration(0, 0)).toBe('PT0S');
expect(formatJsDuration(2, 0)).toBe('P2D');
expect(formatJsDuration(1, 3600)).toBe('P1DT1H');
expect(formatJsDuration(0, 5400)).toBe('PT1H30M');
// Minutes stay between hours and seconds.
expect(formatJsDuration(0, 3605)).toBe('PT1H0M5S');
expect(formatJsDuration(0, 45)).toBe('PT45S');
expect(formatJsDuration(0, 59.6)).toBe('PT1M');
expect(formatJsDurationSeconds(90_000)).toBe('P1DT1H');
expect(formatJsDuration(-1, -5)).toBe('PT0S');
});
it('writes only what CalendarProvider accepts', () => {
expect(formatAospDuration(5400, false)).toBe('P5400S');
expect(formatAospDuration(0, false)).toBe('P0S');
expect(formatAospDuration(172_800, true)).toBe('P2D');
// A time part on an all-day event is rounded up to whole days; never zero days.
expect(formatAospDuration(90_000, true)).toBe('P2D');
expect(formatAospDuration(0, true)).toBe('P1D');
for (const s of [0, 1, 59, 3600, 86_400, 1_000_000]) {
expect(aospDurationValid(formatAospDuration(s, false))).toBe(true);
expect(formatAospDuration(s, true)).toMatch(/^P\d+D$/);
}
});
it('counts whole days nominally in the zone and the rest exactly', () => {
const berlin = 'Europe/Berlin';
// 23 h across the spring change are still one day.
const start = localToUtc('2026-03-28T12:00:00', berlin)!;
const end = localToUtc('2026-03-29T12:00:00', berlin)!;
expect(end - start).toBe(23 * 3_600_000);
expect(durationBetween(start, end, berlin)).toEqual({ negative: false, days: 1, seconds: 0 });
expect(addDuration(start, { negative: false, days: 1, seconds: 0 }, berlin)).toBe(end);
expect(durationBetween(start, end + 1_800_000, berlin)).toEqual({ negative: false, days: 1, seconds: 1800 });
expect(durationBetween(start, start + 5_400_000, berlin)).toEqual({ negative: false, days: 0, seconds: 5400 });
expect(durationBetween(end, start, berlin)).toEqual({ negative: false, days: 0, seconds: 0 });
});
it('keeps the second instant of a repeated hour when adding exact time', () => {
const second = Date.parse('2026-10-25T01:30:00Z'); // 02:30 CET, after the change
expect(addDuration(second, { negative: false, days: 0, seconds: 3600 }, 'Europe/Berlin')).toBe(second + 3_600_000);
});
it('compares lengths with a day as 24 hours', () => {
expect(sameDurationLength('PT24H', 'P1D')).toBe(true);
expect(sameDurationLength('P3600S', 'PT1H')).toBe(true);
expect(sameDurationLength('PT1H', 'PT2H')).toBe(false);
});
});
@@ -0,0 +1,66 @@
/**
* Upload → server → accepted plan → the server's echo: after every kind of
* device edit, the rows must already be what the server's next download
* describes, so that echo writes nothing (docs/device-sync.md, invariant 4).
*/
import { describe, expect, it } from 'vitest';
import { Attendees, Events } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import type { CalendarEventWire } from '../../wire';
import { ACCT, Harness, ME, serverApply, single, utc, weekly } from './harness';
async function roundTrip(event: CalendarEventWire, edit: (h: Harness, id: number) => void | Promise<void>) {
const h = await new Harness().setup();
await h.download(event);
const id = Number(h.masterRow(event.id)._id);
await edit(h, id);
const local = (await h.local(event.id))!;
const plan = calendarPlanner.planUpload(local, h.ctx);
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(`expected an update: ${JSON.stringify(plan)}`);
const server = { ...serverApply(event, plan.actions[0].patch), updated: '2026-09-27T13:00:00Z' };
await h.apply(calendarPlanner.planAccepted(local, server, h.ctx).ops);
const after = (await h.local(event.id))!;
return { h, id, server, after, patch: plan.actions[0].patch };
}
const EDITS: Array<[string, () => CalendarEventWire, (h: Harness, id: number) => void | Promise<void>]> = [
['a title', weekly, (h, id) => h.fake.user.updateEvent(id, { [Events.TITLE]: 'T' })],
['a start of a single event', () => single(), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z'), [Events.DTEND]: utc('2026-10-06T12:00:00Z') })],
['a DST-ambiguous start (fixed-offset zone)', () => single(), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T00:30:00Z'), [Events.DTEND]: utc('2026-10-25T01:30:00Z') })],
['an "all events" move', weekly, (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-05T14:00:00Z') })],
['a "this event" edit', weekly, (h, id) => void h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), { [Events.TITLE]: 'Only this', [Events.DTSTART]: utc('2026-10-14T13:00:00Z'), [Events.DTEND]: utc('2026-10-14T14:30:00Z') })],
['a cancelled instance', weekly, (h, id) => void h.fake.user.cancelInstance(id, utc('2026-10-19T13:00:00Z'), utc('2026-10-19T14:30:00Z'))],
['a capped rule', weekly, (h, id) => h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;UNTIL=20261010T000000Z;BYDAY=MO,WE' })],
['an RSVP', weekly, (h, id) => h.fake.user.setAttendeeStatus(id, ME, 4)],
['an added attendee', weekly, (h, id) => void h.fake.user.addAttendee(id, { [Attendees.ATTENDEE_EMAIL]: 'x@example.net', [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 3, [Attendees.ATTENDEE_RELATIONSHIP]: 1 })],
['reminders', weekly, (h, id) => h.fake.user.setReminders(id, [{ minutes: 5 }, { minutes: 1440, method: 2 }])],
['a location on an event without one', () => single(), (h, id) => h.fake.user.updateEvent(id, { [Events.EVENT_LOCATION]: 'Hall' })],
['an all-day event\'s dates', () => single({ id: 'a', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D' }), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-09T00:00:00Z'), [Events.DTEND]: utc('2026-10-12T00:00:00Z') })],
];
describe('calendar planner: accepted uploads leave nothing for the echo', () => {
it.each(EDITS)('%s', async (_name, make, edit) => {
const { h, server, after } = await roundTrip(make(), edit);
expect(after.dirty).toBe(false);
expect(after.exceptions.every((x) => !x.dirty)).toBe(true);
const echo = calendarPlanner.planDownload(server, after, h.ctx);
expect(echo).toMatchObject({ effect: 'none', writes: 0 });
expect(calendarPlanner.planBaselineHeal(after)).toBeNull();
});
it('re-keys the exception rows with the series after an "all events" move', async () => {
const { after, patch } = await roundTrip(weekly(), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-05T14:00:00Z') }));
expect(Object.keys(patch.recurrenceOverrides as object).sort()).toEqual(['2026-10-07T10:00:00', '2026-10-12T10:00:00']);
expect(after.exceptions.map((x) => [x.syncId, x.cells[Events.ORIGINAL_INSTANCE_TIME]])).toEqual([
[`${ACCT}/bl#2026-10-07T10:00:00`, utc('2026-10-07T14:00:00Z')],
]);
});
it('drops the exception rows of overrides a "this and following" cut pruned', async () => {
const event = weekly();
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] };
event.recurrenceOverrides = { '2026-10-07T09:00:00': { title: 'Early' }, '2026-10-21T09:00:00': { title: 'Late' } };
const { after } = await roundTrip(event, (h, id) => h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;UNTIL=20261019T125959Z;BYDAY=MO,WE' }));
expect(after.exceptions.map((x) => x.recurrenceId)).toEqual(['2026-10-07T09:00:00']);
});
});
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { Events } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import type { CalendarEventWire } from '../../wire';
import { ACCT, Harness, single, utc, weekly } from './harness';
async function synced(event: CalendarEventWire) {
const h = await new Harness().setup();
await h.download(event);
return { h, id: Number(h.masterRow(event.id)._id) };
}
describe('calendar planner: edge cases', () => {
it('undoes a deletion in a read-only calendar by purging and refetching', async () => {
const { h, id } = await synced(single({ calendarIds: { r: true } }));
h.fake.user.deleteEvent(id);
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
expect(plan).toMatchObject({ kind: 'revert', refetch: true });
if (plan.kind === 'revert') await h.apply(plan.ops);
expect(await h.events()).toEqual([]);
});
it('treats an instance Stalwart holds without its series as read-only', async () => {
const { h, id } = await synced(single({ recurrenceId: '2026-10-06T12:00:00', recurrenceIdTimeZone: 'Europe/Berlin' }));
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Mine' });
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
expect(plan.kind).toBe('revert');
if (plan.kind === 'revert') await h.apply(plan.ops);
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Lunch', [Events.DIRTY]: 0 });
});
it('pairs Etar\'s move of a series with its copied exceptions and adopts them', async () => {
const { h, id } = await synced(weekly());
h.fake.user.deleteEvent(id);
const fresh = h.fake.user.insertEvent(h.rowIds.get('w')!, {
[Events.TITLE]: 'Probe weekly', [Events.DESCRIPTION]: 'desc', [Events.EVENT_LOCATION]: 'Room 1', [Events.DTSTART]: utc('2026-10-05T13:00:00Z'),
[Events.DURATION]: 'P5400S', [Events.RRULE]: 'FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE', [Events.EVENT_TIMEZONE]: 'America/New_York',
[Events.STATUS]: 0, [Events.AVAILABILITY]: 1, [Events.ACCESS_LEVEL]: 2, [Events.EVENT_COLOR]: h.row(id)[Events.EVENT_COLOR],
});
for (const a of h.fake.rows('attendees', 'event_id = ?', [id])) {
h.fake.table('attendees').set(h.fake.allocId(), { ...a, _id: null, event_id: fresh });
}
// Etar copies the exception too (API 30+).
h.fake.user.insertException(fresh, utc('2026-10-07T13:00:00Z'), { [Events.TITLE]: 'Moved Wednesday', [Events.DTSTART]: utc('2026-10-07T15:00:00Z'), [Events.DTEND]: utc('2026-10-07T16:30:00Z') });
const events = await h.events();
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId && !e.deleted), h.ctx);
expect(pairs.map((p) => p.actions)).toEqual([[{ kind: 'update', id: 'bl', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]]);
await h.apply(pairs[0].ops);
const after = (await h.local('bl'))!;
expect(after.eventId).toBe(fresh);
expect(after.exceptions.map((x) => [x.syncId, x.dirty])).toEqual([[`${ACCT}/bl#2026-10-07T09:00:00`, false]]);
// The old row and its own exception are gone.
expect((await h.events()).map((e) => e.eventId)).toEqual([fresh]);
});
it('compares descriptions over 1 KB by hash and uploads them only when edited', async () => {
const long = 'Lorem ipsum '.repeat(200);
const { h, id } = await synced(single({ description: long }));
const local = (await h.local('e1'))!;
expect(String(local.baseline!.cells[Events.DESCRIPTION])).toMatch(/^sha256:[0-9a-f]{64}$/);
h.fake.user.updateEvent(id, {});
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx).kind).toBe('clean');
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: `${long}!` });
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
expect(plan.kind === 'upload' && plan.actions[0].kind === 'update' && plan.actions[0].patch).toEqual({ description: `${long}!` });
});
it('writes nothing into a split clone on download: it is a new event', async () => {
const event = single({ recurrenceRule: { frequency: 'daily' } });
const { h, id } = await synced(event);
const clone = h.fake.user.splitViaUri(id, utc('2026-10-10T10:00:00Z'), '20261010T095959Z', {});
const cloneEvent = (await h.events()).find((e) => e.eventId === clone)!;
expect(calendarPlanner.planDownload({ ...event, title: 'x' }, cloneEvent, h.ctx)).toMatchObject({ effect: 'none', stillDirty: true });
});
it('keeps an exception row an app left without a description or attendees inheriting them', async () => {
const { h, id } = await synced(weekly());
h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), { [Events.DTSTART]: utc('2026-10-14T13:00:00Z'), [Events.DTEND]: utc('2026-10-14T14:30:00Z'), [Events.EVENT_LOCATION]: null });
const plan = calendarPlanner.planUpload((await h.local('bl'))!, h.ctx);
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error();
const override = plan.actions[0].patch['recurrenceOverrides/2026-10-14T09:00:00'] as Record<string, unknown>;
// The series' description, location and attendees stay on the instance.
expect(override).toMatchObject({ description: 'desc', locations: weekly().locations, participants: weekly().participants });
});
});
@@ -0,0 +1,355 @@
import { describe, expect, it } from 'vitest';
import { Attendees, Events } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import type { LocalEvent, UploadPlan } from '../../planner';
import type { CalendarEventWire } from '../../wire';
import { ACCT, Harness, ME, serverApply, single, utc, weekly } from './harness';
type Plan = UploadPlan<CalendarEventWire>;
function patchOf(plan: Plan) {
expect(plan.kind).toBe('upload');
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(JSON.stringify(plan));
return plan.actions[0];
}
async function synced(event: CalendarEventWire) {
const h = await new Harness().setup();
await h.download(event);
return { h, id: Number(h.masterRow(event.id)._id) };
}
/** Uploads, lets the server apply the patch, applies the accepted plan and returns the new server object. */
async function accept(h: Harness, event: CalendarEventWire, local: LocalEvent, plan: Plan): Promise<CalendarEventWire> {
const action = patchOf(plan);
const server = serverApply(event, action.patch);
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
await h.apply(accepted.ops);
return server;
}
const NO_FORBIDDEN = ['@type', 'organizerCalendarAddress', 'privacy', 'uid', 'recurrenceRule', 'recurrenceOverrides', 'recurrenceId', 'method', 'prodId', 'sentBy'];
describe('calendar planner: recurrence edits made on the device', () => {
it('uploads an Etar "this event" edit as a new override carrying the master\'s details', async () => {
const { h, id } = await synced(weekly());
h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), {
[Events.TITLE]: 'Only this Wednesday',
[Events.DTSTART]: utc('2026-10-14T14:00:00Z'),
[Events.DTEND]: utc('2026-10-14T15:30:00Z'),
});
const local = (await h.local('bl'))!;
expect(local.exceptions.find((x) => !x.cells[Events.SYNC_DATA2])?.recurrenceId).toBe('2026-10-14T09:00:00');
const plan = calendarPlanner.planUpload(local, h.ctx);
const action = patchOf(plan);
expect(Object.keys(action.patch)).toEqual(['recurrenceOverrides/2026-10-14T09:00:00']);
const override = action.patch['recurrenceOverrides/2026-10-14T09:00:00'] as Record<string, unknown>;
expect(override).toMatchObject({
title: 'Only this Wednesday',
start: '2026-10-14T10:00:00',
duration: 'PT1H30M',
locations: weekly().locations,
participants: weekly().participants,
description: 'desc',
});
for (const key of NO_FORBIDDEN) expect(override).not.toHaveProperty(key);
// The organizer changed an instance: attendees are told.
expect(action.sendSchedulingMessages).toBe(true);
const server = await accept(h, weekly(), local, plan);
const after = (await h.local('bl'))!;
expect(after.dirty).toBe(false);
expect(after.exceptions.map((x) => [x.syncId, x.dirty])).toEqual([
[`${ACCT}/bl#2026-10-07T09:00:00`, false],
[`${ACCT}/bl#2026-10-14T09:00:00`, false],
]);
// The download of our own change writes nothing.
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
});
it('sends the whole overrides map for the first override of a series', async () => {
const event = single({ recurrenceRule: { frequency: 'daily', count: 5 } });
const { h, id } = await synced(event);
h.fake.user.insertException(id, utc('2026-10-07T10:00:00Z'), { [Events.TITLE]: 'Tuesday lunch', [Events.DTSTART]: utc('2026-10-07T10:00:00Z'), [Events.DTEND]: utc('2026-10-07T11:00:00Z') });
const action = patchOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
expect(Object.keys(action.patch)).toEqual(['recurrenceOverrides']);
expect(action.patch.recurrenceOverrides).toEqual({
'2026-10-07T12:00:00': { title: 'Tuesday lunch', start: '2026-10-07T12:00:00', duration: 'PT1H' },
});
});
it('uploads "delete this event" (a cancelled exception) as an exclusion and cleans up after', async () => {
const { h, id } = await synced(weekly());
h.fake.user.cancelInstance(id, utc('2026-10-14T13:00:00Z'), utc('2026-10-14T14:30:00Z'));
const local = (await h.local('bl'))!;
const plan = calendarPlanner.planUpload(local, h.ctx);
expect(patchOf(plan).patch).toEqual({ 'recurrenceOverrides/2026-10-14T09:00:00': { excluded: true } });
const server = await accept(h, weekly(), local, plan);
expect(h.masterRow('bl')[Events.EXDATE]).toBe('20261012T130000Z,20261014T130000Z');
const after = (await h.local('bl'))!;
expect(after.exceptions.map((x) => x.recurrenceId)).toEqual(['2026-10-07T09:00:00']);
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
});
it('excludes an instance whose synced exception row was deleted', async () => {
const { h } = await synced(weekly());
const x = (await h.local('bl'))!.exceptions[0];
h.fake.user.deleteEvent(x.eventId);
expect(h.row(x.eventId)).toMatchObject({ [Events.DELETED]: 1, [Events.DIRTY]: 1 });
const local = (await h.local('bl'))!;
const plan = calendarPlanner.planUpload(local, h.ctx);
expect(patchOf(plan).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00': { excluded: true } });
await accept(h, weekly(), local, plan);
expect(h.row(x.eventId)).toBeUndefined();
});
it('patches an existing override one level deep, never deeper', async () => {
const { h } = await synced(weekly());
const x = (await h.local('bl'))!.exceptions[0];
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Moved again', [Events.EVENT_LOCATION]: 'Annex' });
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.patch).toEqual({
'recurrenceOverrides/2026-10-07T09:00:00/title': 'Moved again',
'recurrenceOverrides/2026-10-07T09:00:00/locations': { locA: { name: 'Annex', '@type': 'Location' } },
});
});
it('answers one instance (CONTENT_EXCEPTION_URI) with the override\'s whole participants map', async () => {
const invite = weekly();
invite.organizerCalendarAddress = 'mailto:boss@example.net';
invite.participants = {
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' },
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
other: { calendarAddress: 'mailto:other@example.net', roles: { attendee: true }, participationStatus: 'accepted' },
};
const { h, id } = await synced(invite);
h.fake.user.exceptionViaUri(id, utc('2026-10-19T13:00:00Z'), { [Events.SELF_ATTENDEE_STATUS]: 1, [Events.STATUS]: 1 });
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
const override = action.patch['recurrenceOverrides/2026-10-19T09:00:00'] as Record<string, unknown>;
expect(override.participants).toEqual({ ...invite.participants, me: { ...invite.participants!.me, participationStatus: 'accepted' } });
// Etar's STATUS_CONFIRMED on the instance is not the attendee's to upload: the series' status is kept.
expect(override).toMatchObject({ status: 'tentative', start: '2026-10-19T09:00:00', duration: 'PT1H30M', title: 'Probe weekly' });
for (const key of NO_FORBIDDEN) expect(override).not.toHaveProperty(key);
// An attendee's RSVP sends the REPLY.
expect(action.sendSchedulingMessages).toBe(true);
});
it('turns EXDATE entries an app adds or removes into exclusions and re-inclusions', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.EXDATE]: '20261012T130000Z\nAmerica/New_York;20261021T090000' });
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
'recurrenceOverrides/2026-10-21T09:00:00': { excluded: true },
});
h.fake.user.updateEvent(id, { [Events.EXDATE]: null });
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
'recurrenceOverrides/2026-10-12T09:00:00': null,
});
});
it('uploads Etar\'s "this and following" as a capped rule, prunes later overrides, and creates the new series', async () => {
const event = weekly();
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] };
event.recurrenceOverrides = {
'2026-10-07T09:00:00': { title: 'Moved Wednesday', start: '2026-10-07T11:00:00' },
'2026-10-21T09:00:00': { title: 'Later one' },
'2026-10-26T09:00:00': { excluded: true },
};
const { h, id } = await synced(event);
// Split at Mon 2026-10-19 09:00 New York: UNTIL = the instance minus one second, in UTC.
h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;UNTIL=20261019T125959Z;BYDAY=MO,WE' });
const fresh = h.fake.user.insertEvent(h.rowIds.get('b')!, {
[Events.TITLE]: 'Probe weekly (new time)',
[Events.DTSTART]: utc('2026-10-19T14:00:00Z'),
[Events.DURATION]: 'P5400S',
[Events.RRULE]: 'FREQ=WEEKLY;BYDAY=MO,WE',
[Events.EVENT_TIMEZONE]: 'America/New_York',
[Events.STATUS]: 1,
});
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.patch).toEqual({
recurrenceRule: { frequency: 'weekly', until: '2026-10-19T08:59:59', byDay: [{ day: 'mo' }, { day: 'we' }] },
'recurrenceOverrides/2026-10-21T09:00:00': null,
'recurrenceOverrides/2026-10-26T09:00:00': null,
});
// The new series is a new event: claimed with a fresh uid, then created.
const newRow = await h.byRow(fresh);
const claim = calendarPlanner.planUpload(newRow, h.ctx);
expect(claim.kind).toBe('claim');
if (claim.kind === 'claim') await h.apply(claim.ops);
const claimed = await h.byRow(fresh);
expect(claimed.syncId).toMatch(/^~pending\//);
expect(claimed.pending).toEqual({ uid: claimed.cells[Events.UID_2445], target: `${ACCT}/b` });
const create = calendarPlanner.planUpload(claimed, h.ctx);
expect(create.kind).toBe('upload');
if (create.kind !== 'upload' || create.actions[0].kind !== 'create') throw new Error('no create');
expect(create.actions[0]).toMatchObject({
kind: 'create',
uid: claimed.pending!.uid,
collectionId: 'b',
object: {
'@type': 'Event',
uid: claimed.pending!.uid,
calendarIds: { b: true },
title: 'Probe weekly (new time)',
start: '2026-10-19T10:00:00',
timeZone: 'America/New_York',
duration: 'PT1H30M',
recurrenceRule: { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] },
},
});
});
it('prunes overrides after a series Etar capped with a lower COUNT, using the provider\'s LAST_DATE', async () => {
const { h, id } = await synced(weekly());
// COUNT 10 → 3 (Mon 5, Wed 7, Mon 12); the provider computed LAST_DATE = last start + duration.
h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;COUNT=3;BYDAY=MO,WE' });
h.fake.table('events').get(id)![Events.LAST_DATE] = utc('2026-10-12T13:00:00Z') + 5_400_000;
const event = weekly();
event.recurrenceOverrides!['2026-10-14T09:00:00'] = { title: 'x' };
const local = (await h.local('bl'))!;
local.shadow = event;
expect(patchOf(calendarPlanner.planUpload(local, h.ctx)).patch).toEqual({
recurrenceRule: { frequency: 'weekly', count: 3, byDay: [{ day: 'mo' }, { day: 'we' }] },
'recurrenceOverrides/2026-10-14T09:00:00': null,
});
});
it('moves every recurrence id with an "all events" start change', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-05T14:00:00Z') });
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.patch).toEqual({
start: '2026-10-05T10:00:00',
recurrenceOverrides: {
'2026-10-12T10:00:00': { excluded: true },
// Its own start was a move elsewhere: it stays.
'2026-10-07T10:00:00': { updated: '2026-09-26T22:57:38Z', title: 'Moved Wednesday', start: '2026-10-07T11:00:00' },
},
});
});
it('resolves a CONTENT_EXCEPTION_URI split: the clone becomes a new event, the source uploads its capped rule', async () => {
const event = weekly();
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] };
event.recurrenceOverrides = { '2026-10-07T09:00:00': { title: 'Moved Wednesday', start: '2026-10-07T11:00:00' }, '2026-10-21T09:00:00': { title: 'Later' } };
const { h, id } = await synced(event);
const clone = h.fake.user.splitViaUri(id, utc('2026-10-19T13:00:00Z'), '20261019T125959Z', { [Events.TITLE]: 'New series' });
const all = await h.events();
const source = all.find((e) => e.eventId === id)!;
const cloneEvent = all.find((e) => e.eventId === clone)!;
expect([source.split, cloneEvent.split]).toEqual(['source', 'clone']);
expect(source.dirty).toBe(false);
// The source's rule was capped without DIRTY; it uploads as it stands.
expect(patchOf(calendarPlanner.planUpload(source, h.ctx)).patch).toEqual({
recurrenceRule: { frequency: 'weekly', until: '2026-10-19T08:59:59', byDay: [{ day: 'mo' }, { day: 'we' }] },
'recurrenceOverrides/2026-10-21T09:00:00': null,
});
// The clone is claimed under a fresh uid, drops the source's sync data and marks the source dirty.
const claim = calendarPlanner.planUpload(cloneEvent, h.ctx);
expect(claim.kind).toBe('claim');
if (claim.kind === 'claim') await h.apply(claim.ops);
const after = await h.events();
const claimed = after.find((e) => e.eventId === clone)!;
expect(claimed.syncId).toMatch(/^~pending\//);
expect(claimed.cells[Events.UID_2445]).not.toBe(event.uid);
expect(claimed.shadow).toBeNull();
expect(claimed.split).toBeUndefined();
const sourceAfter = after.find((e) => e.eventId === id)!;
expect(sourceAfter.dirty).toBe(true);
// The source keeps its exceptions (and their link) whatever the provider's trigger did.
expect(sourceAfter.exceptions.map((x) => x.cells[Events.ORIGINAL_SYNC_ID])).toEqual([`${ACCT}/bl`, `${ACCT}/bl`]);
expect(patchOf(calendarPlanner.planUpload(sourceAfter, h.ctx)).patch.recurrenceRule).toMatchObject({ until: '2026-10-19T08:59:59' });
});
it('keeps a local exception edit through a download that changed another instance', async () => {
const { h } = await synced(weekly());
const x = (await h.local('bl'))!.exceptions[0];
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Local title' });
const remote = weekly();
remote.recurrenceOverrides!['2026-10-19T09:00:00'] = { title: 'Remote added' };
const plan = await h.download(remote);
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
const after = (await h.local('bl'))!;
expect(after.exceptions.map((e) => [e.recurrenceId, e.cells[Events.TITLE], e.dirty])).toEqual([
['2026-10-07T09:00:00', 'Local title', true],
['2026-10-19T09:00:00', 'Remote added', false],
]);
expect(patchOf(calendarPlanner.planUpload(after, h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00/title': 'Local title' });
});
it('lets a server removal of an override win over a local edit of it', async () => {
const { h } = await synced(weekly());
const x = (await h.local('bl'))!.exceptions[0];
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Local title' });
const remote = weekly();
delete remote.recurrenceOverrides!['2026-10-07T09:00:00'];
const plan = await h.download(remote);
expect(plan.conflicts).toBe(1);
expect(h.row(x.eventId)).toBeUndefined();
});
it('lets a device deletion of an instance win over a server edit of it', async () => {
const { h } = await synced(weekly());
const x = (await h.local('bl'))!.exceptions[0];
h.fake.user.deleteEvent(x.eventId);
const remote = weekly();
remote.recurrenceOverrides!['2026-10-07T09:00:00'] = { title: 'Remote title', start: '2026-10-07T11:00:00' };
const plan = await h.download(remote);
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
'recurrenceOverrides/2026-10-07T09:00:00': { excluded: true },
});
});
it('writes an all-day series\' exceptions at UTC midnight and keys them by date', async () => {
const event = single({
id: 'ad',
start: '2026-10-05T00:00:00',
timeZone: null,
showWithoutTime: true,
duration: 'P1D',
recurrenceRule: { frequency: 'daily', count: 5 },
recurrenceOverrides: { '2026-10-06T00:00:00': { title: 'Day two' }, '2026-10-07T00:00:00': { excluded: true } },
});
const { h, id } = await synced(event);
expect(h.row(id)).toMatchObject({ [Events.EXDATE]: '20261007', [Events.DURATION]: 'P1D' });
const [x] = (await h.local('ad'))!.exceptions;
expect(x.cells).toMatchObject({ [Events.ORIGINAL_INSTANCE_TIME]: utc('2026-10-06T00:00:00Z'), [Events.ORIGINAL_ALL_DAY]: 1, [Events.DTSTART]: utc('2026-10-06T00:00:00Z'), [Events.DTEND]: utc('2026-10-07T00:00:00Z') });
// Etar cancels day four.
h.fake.user.cancelInstance(id, utc('2026-10-08T00:00:00Z'), utc('2026-10-09T00:00:00Z'));
expect(patchOf(calendarPlanner.planUpload((await h.local('ad'))!, h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-08T00:00:00': { excluded: true } });
});
it('skips a series whose instance to change is a repeated wall time', async () => {
const event = single({ id: 'n', start: '2026-10-20T02:30:00', recurrenceRule: { frequency: 'daily' } });
const { h, id } = await synced(event);
// Etar "this event" on the 25th: 02:30 happens twice that night in Berlin.
h.fake.user.insertException(id, utc('2026-10-25T00:30:00Z'), { [Events.TITLE]: 'x', [Events.DTSTART]: utc('2026-10-25T00:30:00Z'), [Events.DTEND]: utc('2026-10-25T01:30:00Z') });
expect(calendarPlanner.planUpload((await h.local('n'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'dstAmbiguous' });
});
it('moves an UNTIL that falls into a DST gap or overlap one hour later', async () => {
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
// Until 2026-10-25 00:30Z = 02:30 CEST, a repeated wall time Stalwart would drop.
h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=DAILY;UNTIL=20261025T003000Z' });
expect(patchOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({
recurrenceRule: { frequency: 'daily', until: '2026-10-25T03:30:00' },
});
});
it('keeps the organizer and other attendees on the instance a pointer RSVP would have dropped', async () => {
const { h, id } = await synced(weekly());
const x = (await h.local('bl'))!.exceptions[0];
h.fake.table('attendees').forEach((a) => {
if (a.event_id === x.eventId && a[Attendees.ATTENDEE_EMAIL] === ME) a[Attendees.ATTENDEE_STATUS] = 2;
});
h.fake.user.updateEvent(x.eventId, {});
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(Object.keys(action.patch)).toEqual(['recurrenceOverrides/2026-10-07T09:00:00/participants']);
expect(Object.keys(action.patch['recurrenceOverrides/2026-10-07T09:00:00/participants'] as object)).toEqual(['me1', 'guestX']);
expect(id).toBeGreaterThan(0);
});
});
@@ -0,0 +1,232 @@
/**
* Test harness for the calendar planner: the fake CalendarProvider, a
* CalendarContext over a few server calendars, and helpers that plan, apply
* the op groups the way the engine does and read the rows back.
*/
import { expect } from 'vitest';
import { FakeDeviceProviders } from '../fakes/fake-provider';
import { CALENDAR_AUTHORITY, type ProviderPort, type Row } from '../../types';
import type { CalendarContext, LocalCalendar, LocalEvent, OpGroup } from '../../planner';
import type { CalendarEventWire, CalendarLike } from '../../wire';
import { calendarPlanner } from '../../calendar/planner';
import { applyPatch, type PatchObject } from '../../common/patch';
import { makeKeyMinter, parseCollectionKey, uuidFrom } from '../../common/ids';
import { Events } from '../../android-columns';
export const ACCOUNT = 'usera@example.org';
export const ME = 'usera@example.org';
export const ALIAS = 'alias@example.org';
export const ACCT = 'c';
export function seeded(seed: number) {
return () => {
seed = (seed * 1103515245 + 12345) % 2147483648;
return seed / 2147483648;
};
}
export const CALENDARS: Record<string, CalendarLike> = {
b: {
id: 'b',
name: 'Personal',
color: '#3b82f6',
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayRSVP: true, mayDelete: true },
defaultAlertsWithTime: { d1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT10M' }, action: 'display' } },
defaultAlertsWithoutTime: { d2: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15H' }, action: 'display' } },
} as CalendarLike,
w: { id: 'w', name: 'Work', color: '#22c55e', myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayRSVP: true } } as CalendarLike,
r: { id: 'r', name: 'Team (read)', myRights: { mayReadItems: true, mayRSVP: true } } as CalendarLike,
};
export interface HarnessOptions {
deviceZone?: string;
reminderOwner?: 'device' | 'bulwark';
readOnly?: string[];
now?: number;
}
export class Harness {
readonly fake = new FakeDeviceProviders();
readonly port: ProviderPort = this.fake.port(ACCOUNT, CALENDAR_AUTHORITY);
readonly rowIds = new Map<string, number>();
ctx: CalendarContext;
constructor(options: HarnessOptions = {}) {
const random = seeded(7);
const readOnly = new Set(options.readOnly ?? ['r']);
this.ctx = {
jmapAccountId: ACCT,
now: options.now ?? Date.parse('2026-09-27T12:00:00Z'),
mintKey: makeKeyMinter(random),
mintUid: () => uuidFrom(random),
deviceZone: options.deviceZone ?? 'Europe/Berlin',
ownerAccount: ME,
selfAddresses: [ME, ALIAS],
reminderOwner: options.reminderOwner ?? 'device',
calendar: (id) => CALENDARS[id],
calendarRowId: (id) => this.rowIds.get(id) ?? null,
calendarIdOfRow: (rowId) => {
for (const [id, row] of this.rowIds) if (row === rowId) return { jmapAccountId: ACCT, calendarId: id };
return null;
},
isReadOnly: (id) => readOnly.has(id),
isSelected: (key) => parseCollectionKey(key)?.accountId === ACCT,
};
}
/** Inserts the calendar rows through planCalendars. */
async setup(ids: string[] = ['b', 'w', 'r']): Promise<this> {
const plan = calendarPlanner.planCalendars(
ids.map((id) => ({ jmapAccountId: ACCT, calendar: CALENDARS[id], readOnly: this.ctx.isReadOnly(id), accountName: ME })),
[],
this.ctx,
);
for (const group of plan.groups) await this.apply(group);
for (const cal of await this.calendars()) this.rowIds.set(parseCollectionKey(cal.syncId)!.id, cal.calendarRowId);
return this;
}
async apply(group: OpGroup): Promise<void> {
const result = await this.port.applyBatch(group.ops);
if (!result.ok) throw new Error(`batch ${group.ref} failed: ${result.reason} ${result.message}`);
}
async tryApply(group: OpGroup) {
return this.port.applyBatch(group.ops);
}
async query(table: 'events' | 'attendees' | 'reminders' | 'calendars', columns: readonly string[]): Promise<Row[]> {
const res = await this.port.query({ table, columns: [...columns] });
return res.rows.map((cells) => Object.fromEntries(res.columns.map((c, i) => [c, cells[i]])));
}
async calendars(): Promise<LocalCalendar[]> {
return (await this.query('calendars', calendarPlanner.calendarColumns)).map(calendarPlanner.decodeCalendar);
}
async events(): Promise<LocalEvent[]> {
return calendarPlanner.decodeEvents(
await this.query('events', calendarPlanner.eventColumns),
await this.query('attendees', calendarPlanner.attendeeColumns),
await this.query('reminders', calendarPlanner.reminderColumns),
);
}
/** The local event with this server id (or null). */
async local(id: string): Promise<LocalEvent | null> {
return (await this.events()).find((e) => e.syncId === `${ACCT}/${id}`) ?? null;
}
async byRow(eventId: number): Promise<LocalEvent> {
const all = await this.events();
const found = all.find((e) => e.eventId === eventId);
if (!found) throw new Error(`no event row ${eventId}`);
return found;
}
/** Plans and applies a download of `event`, then heals baselines like the engine's read-back. */
async download(event: CalendarEventWire) {
const local = await this.local(event.id);
const plan = calendarPlanner.planDownload(event, local, this.ctx);
if (plan.effect !== 'none') await this.apply(plan.ops);
await this.heal(event.id);
return plan;
}
async heal(id: string): Promise<void> {
const local = await this.local(id);
if (!local) return;
const heal = calendarPlanner.planBaselineHeal(local);
if (heal) await this.apply(heal);
}
row(eventId: number): Row {
return this.fake.row('events', eventId)!;
}
masterRow(id: string): Row {
const rows = this.fake.rows('events', `${Events._SYNC_ID} = ?`, [`${ACCT}/${id}`]);
expect(rows).toHaveLength(1);
return rows[0];
}
}
/** The server applying an accepted patch (Stalwart's normalisation of what matters here). */
export function serverApply(event: CalendarEventWire, patch: PatchObject): CalendarEventWire {
const next = applyPatch(event, patch);
if (!next) throw new Error(`patch does not apply: ${JSON.stringify(patch)}`);
const overrides = next.recurrenceOverrides;
if (overrides) {
for (const [key, value] of Object.entries(overrides)) {
if (!value || typeof value !== 'object') continue;
if ((value as Record<string, unknown>).excluded === true) {
overrides[key] = { excluded: true };
continue;
}
for (const forbidden of ['@type', 'method', 'organizerCalendarAddress', 'privacy', 'prodId', 'recurrenceId', 'recurrenceIdTimeZone', 'sentBy', 'uid', 'recurrenceRule', 'recurrenceOverrides']) {
delete (value as Record<string, unknown>)[forbidden];
}
}
if (!Object.keys(overrides).length) delete next.recurrenceOverrides;
}
for (const map of ['participants', 'alerts', 'locations'] as const) {
if (next[map] && !Object.keys(next[map] as object).length) delete next[map];
}
return next;
}
/** The probe's weekly event as Stalwart returned it (scratchpad/probe/calendar.out.txt). */
export function weekly(): CalendarEventWire {
return {
id: 'bl',
'@type': 'Event',
uid: 'probe-rec@example.org',
calendarIds: { b: true },
title: 'Probe weekly',
description: 'desc',
start: '2026-10-05T09:00:00',
timeZone: 'America/New_York',
duration: 'PT1H30M',
status: 'tentative',
freeBusyStatus: 'free',
privacy: 'private',
color: 'steelblue',
keywords: { probe: true },
priority: 5,
recurrenceRule: { frequency: 'weekly', count: 10, firstDayOfWeek: 'mo', byDay: [{ day: 'mo' }, { day: 'we' }] },
recurrenceOverrides: {
'2026-10-12T09:00:00': { excluded: true },
'2026-10-07T09:00:00': { updated: '2026-09-26T22:57:38Z', title: 'Moved Wednesday', start: '2026-10-07T11:00:00' },
},
locations: { locA: { name: 'Room 1', '@type': 'Location' } },
participants: {
me1: { calendarAddress: 'mailto:usera@example.org', '@type': 'Participant', participationStatus: 'accepted', roles: { owner: true, attendee: true } },
guestX: { calendarAddress: 'mailto:guest@example.net', '@type': 'Participant', participationStatus: 'needs-action', name: 'Guest', expectReply: true, roles: { attendee: true } },
},
organizerCalendarAddress: 'mailto:usera@example.org',
alerts: {
al1: { action: 'display', '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' } },
alEnd: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: 'PT5M', relativeTo: 'end' } },
alAbs: { '@type': 'Alert', action: 'email', trigger: { '@type': 'AbsoluteTrigger', when: '2026-10-01T08:00:00Z' } },
},
updated: '2026-09-26T22:57:38Z',
isDraft: false,
} as CalendarEventWire;
}
export function single(extra: Partial<CalendarEventWire> = {}): CalendarEventWire {
return {
id: 'e1',
'@type': 'Event',
uid: 'single-1@example.org',
calendarIds: { b: true },
title: 'Lunch',
start: '2026-10-06T12:00:00',
timeZone: 'Europe/Berlin',
duration: 'PT1H',
updated: '2026-09-26T10:00:00Z',
...extra,
} as CalendarEventWire;
}
export const utc = (iso: string) => Date.parse(iso);
@@ -0,0 +1,472 @@
import { describe, expect, it } from 'vitest';
import { Attendees, Events, Reminders } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import type { LocalEvent, UploadPlan } from '../../planner';
import type { CalendarEventWire } from '../../wire';
import { ACCT, Harness, ME, serverApply, single, utc, weekly } from './harness';
type Plan = UploadPlan<CalendarEventWire>;
async function synced(event: CalendarEventWire, options = {}) {
const h = await new Harness(options).setup();
await h.download(event);
return { h, id: Number(h.masterRow(event.id)._id) };
}
function actionsOf(plan: Plan) {
if (plan.kind !== 'upload') throw new Error(`expected an upload, got ${JSON.stringify(plan)}`);
return plan.actions;
}
/** Claims a new row, then returns its create action. */
async function claimAndCreate(h: Harness, eventId: number) {
const claim = calendarPlanner.planUpload(await h.byRow(eventId), h.ctx);
expect(claim.kind).toBe('claim');
if (claim.kind === 'claim') await h.apply(claim.ops);
const local = await h.byRow(eventId);
const [create] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
if (create.kind !== 'create') throw new Error('not a create');
return { local, create };
}
describe('calendar planner: new events', () => {
it('claims, creates and adopts a device event without writing anything on its echo', async () => {
const h = await new Harness().setup();
const id = h.fake.user.insertEvent(
h.rowIds.get('b')!,
{ [Events.TITLE]: 'Dentist', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.EVENT_LOCATION]: 'Main St 1', [Events.STATUS]: 1, [Events.ACCESS_LEVEL]: 2, [Events.AVAILABILITY]: 0 },
{ reminders: [{ [Reminders.MINUTES]: 30, [Reminders.METHOD]: 1 }] },
);
const { local, create } = await claimAndCreate(h, id);
expect(local.syncId).toBe(`~pending/${create.uid}`);
expect(create).toMatchObject({ collectionId: 'b', uid: local.pending!.uid });
expect(create.sendSchedulingMessages).toBeUndefined();
const object = create.object as Record<string, unknown>;
expect(object).toMatchObject({
'@type': 'Event',
uid: create.uid,
calendarIds: { b: true },
title: 'Dentist',
start: '2026-10-09T10:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
status: 'confirmed',
freeBusyStatus: 'busy',
privacy: 'private',
});
expect(Object.values(object.locations as object)).toEqual([{ '@type': 'Location', name: 'Main St 1' }]);
expect(Object.values(object.alerts as object)).toEqual([{ '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT30M', relativeTo: 'start' }, action: 'display' }]);
// The server stores it; the engine re-fetches it and applies the accepted plan.
const server = { ...(object as CalendarEventWire), id: 'n1', updated: '2026-09-27T12:00:00Z' } as CalendarEventWire;
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
await h.apply(accepted.ops);
const after = (await h.local('n1'))!;
expect(after).toMatchObject({ syncId: `${ACCT}/n1`, dirty: false, pending: null });
expect(after.cells[Events.UID_2445]).toBe(create.uid);
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
});
it('creates a new series with the exceptions and exclusions made before its upload', async () => {
const h = await new Harness().setup();
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, {
[Events.TITLE]: 'Standup',
[Events.DTSTART]: utc('2026-10-05T07:00:00Z'),
[Events.DURATION]: 'P900S',
[Events.RRULE]: 'FREQ=DAILY;COUNT=5',
[Events.EVENT_TIMEZONE]: 'Europe/Berlin',
[Events.STATUS]: 1,
});
// Claimed first (the pending _SYNC_ID lets Etar offer "this event").
const claim = calendarPlanner.planUpload(await h.byRow(id), h.ctx);
if (claim.kind === 'claim') await h.apply(claim.ops);
h.fake.user.insertException(id, utc('2026-10-06T07:00:00Z'), { [Events.TITLE]: 'Standup (late)', [Events.DTSTART]: utc('2026-10-06T08:00:00Z'), [Events.DTEND]: utc('2026-10-06T08:15:00Z') });
h.fake.user.cancelInstance(id, utc('2026-10-07T07:00:00Z'), utc('2026-10-07T07:15:00Z'));
const local = await h.byRow(id);
expect(local.exceptions).toHaveLength(2);
const [create] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
if (create.kind !== 'create') throw new Error('not a create');
expect(create.object.recurrenceRule).toEqual({ frequency: 'daily', count: 5 });
expect(create.object.recurrenceOverrides).toEqual({
// The series' details a new override needs come along (withNewOverrideDetails).
'2026-10-06T09:00:00': { title: 'Standup (late)', start: '2026-10-06T10:00:00', duration: 'PT15M', status: 'confirmed', freeBusyStatus: 'busy' },
'2026-10-07T09:00:00': { excluded: true },
});
const server = { ...(create.object as CalendarEventWire), id: 's1' } as CalendarEventWire;
await h.apply(calendarPlanner.planAccepted(local, server, h.ctx).ops);
const after = (await h.local('s1'))!;
expect(after.exceptions.map((x) => [x.syncId, x.dirty])).toEqual([[`${ACCT}/s1#2026-10-06T09:00:00`, false]]);
expect(h.masterRow('s1')[Events.EXDATE]).toBe('20261007T070000Z');
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
});
it('invites the attendees of a new event as its organizer', async () => {
const h = await new Harness().setup();
const id = h.fake.user.insertEvent(
h.rowIds.get('b')!,
{ [Events.TITLE]: 'Review', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1 },
{
attendees: [
{ [Attendees.ATTENDEE_EMAIL]: ME, [Attendees.ATTENDEE_RELATIONSHIP]: 2, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 1 },
{ [Attendees.ATTENDEE_EMAIL]: 'guest@example.net', [Attendees.ATTENDEE_NAME]: 'Guest', [Attendees.ATTENDEE_RELATIONSHIP]: 1, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 3 },
],
},
);
const { create } = await claimAndCreate(h, id);
expect(create.sendSchedulingMessages).toBe(true);
const participants = Object.values(create.object.participants ?? {});
expect(participants).toEqual([
{ '@type': 'Participant', calendarAddress: `mailto:${ME}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
{ '@type': 'Participant', calendarAddress: 'mailto:guest@example.net', name: 'Guest', roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true },
]);
expect(create.object.organizerCalendarAddress).toBe(`mailto:${ME}`);
});
it('gives a new event carrying another row\'s uid a fresh one', async () => {
const { h } = await synced(single());
const copy = h.fake.user.insertEvent(h.rowIds.get('b')!, {
[Events.TITLE]: 'Copy', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'UTC', [Events.UID_2445]: 'single-1@example.org', [Events.STATUS]: 1,
});
const local = await h.byRow(copy);
expect(local.split).toBe('clone');
const { create } = await claimAndCreate(h, copy);
expect(create.uid).not.toBe('single-1@example.org');
// A uid nobody else carries is kept (an imported invitation keeps its UID).
const own = h.fake.user.insertEvent(h.rowIds.get('b')!, {
[Events.TITLE]: 'Imported', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'UTC', [Events.UID_2445]: 'ics-uid@example.net', [Events.STATUS]: 1,
});
expect((await claimAndCreate(h, own)).create.uid).toBe('ics-uid@example.net');
});
it('adopts its own create found by uid on download, keeping later local edits', async () => {
const h = await new Harness().setup();
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, { [Events.TITLE]: 'Draft', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1 });
const { create } = await claimAndCreate(h, id);
// The create reached the server, but the response was lost; meanwhile the user renamed it.
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Final' });
const server = { ...(create.object as CalendarEventWire), id: 'n7' } as CalendarEventWire;
const plan = calendarPlanner.planDownload(server, await h.byRow(id), h.ctx);
expect(plan).toMatchObject({ effect: 'update', stillDirty: true, conflicts: 0 });
await h.apply(plan.ops);
const adopted = (await h.local('n7'))!;
expect(adopted).toMatchObject({ eventId: id, dirty: true, pending: null });
const [update] = actionsOf(calendarPlanner.planUpload(adopted, h.ctx));
expect(update).toMatchObject({ kind: 'update', id: 'n7', patch: { title: 'Final' } });
});
it('removes a new event created in a read-only calendar', async () => {
const h = await new Harness().setup();
const id = h.fake.user.insertEvent(h.rowIds.get('r')!, { [Events.TITLE]: 'x', [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 });
const plan = calendarPlanner.planUpload(await h.byRow(id), h.ctx);
expect(plan.kind).toBe('revert');
if (plan.kind === 'revert') await h.apply(plan.ops);
expect(h.row(id)).toBeUndefined();
});
});
describe('calendar planner: accepted uploads, merges and deletions', () => {
it('keeps a row edited again during its upload dirty, with the uploaded value as its baseline', async () => {
const { h, id } = await synced(single());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'First' });
const local = (await h.local('e1'))!;
const [update] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
if (update.kind !== 'update') throw new Error();
const server = serverApply(single(), update.patch);
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
// The user edits again before the clearing batch: its assert fails.
h.fake.beforeNextBatch(() => h.fake.user.updateEvent(id, { [Events.TITLE]: 'Second' }));
expect((await h.tryApply(accepted.ops)).ok).toBe(false);
await h.apply(accepted.keepDirtyOps);
const after = (await h.local('e1'))!;
expect(after).toMatchObject({ dirty: true });
expect(after.cells[Events.TITLE]).toBe('Second');
expect(after.shadow?.title).toBe('First');
const [next] = actionsOf(calendarPlanner.planUpload(after, h.ctx));
expect(next).toMatchObject({ patch: { title: 'Second' } });
// A revert to the old value uploads too.
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Lunch' });
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))[0]).toMatchObject({ patch: { title: 'Lunch' } });
});
it('purges a cancelled instance whose exclusion was uploaded even when the series was edited meanwhile', async () => {
const { h, id } = await synced(weekly());
const cancelled = h.fake.user.cancelInstance(id, utc('2026-10-19T13:00:00Z'), utc('2026-10-19T14:30:00Z'));
const local = (await h.local('bl'))!;
const [update] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
if (update.kind !== 'update') throw new Error();
const server = serverApply(weekly(), update.patch);
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
h.fake.beforeNextBatch(() => h.fake.user.updateEvent(id, { [Events.TITLE]: 'Edited meanwhile' }));
expect((await h.tryApply(accepted.ops)).ok).toBe(false);
await h.apply(accepted.keepDirtyOps);
expect(h.row(cancelled)).toBeUndefined();
const after = (await h.local('bl'))!;
const [next] = actionsOf(calendarPlanner.planUpload(after, h.ctx));
// Only the newer edit: the exclusion is on the server already and stays one.
expect(next).toMatchObject({ kind: 'update', patch: { title: 'Edited meanwhile' } });
});
it('fails a download write when the user edits the event meanwhile', async () => {
const { h, id } = await synced(weekly());
const plan = calendarPlanner.planDownload({ ...weekly(), title: 'Server' }, await h.local('bl'), h.ctx);
h.fake.beforeNextBatch(() => h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'mine' }));
const result = await h.tryApply(plan.ops);
expect(result).toMatchObject({ ok: false, reason: 'assert' });
expect(h.row(id)[Events.TITLE]).toBe('Probe weekly');
});
it('merges per unit: local and server changes to different fields both survive', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Local title' });
const plan = await h.download({ ...weekly(), description: 'Server description' });
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Local title', [Events.DESCRIPTION]: 'Server description', [Events.DIRTY]: 1 });
expect(actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))[0]).toMatchObject({ patch: { title: 'Local title' } });
});
it('lets the server win a real conflict and counts it', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Local title' });
const plan = await h.download({ ...weekly(), title: 'Server title' });
expect(plan).toMatchObject({ conflicts: 1, stillDirty: false });
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Server title', [Events.DIRTY]: 0 });
});
it('treats the same change on both sides as converged', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Same' });
const plan = await h.download({ ...weekly(), title: 'Same' });
expect(plan).toMatchObject({ conflicts: 0, stillDirty: false });
expect(h.row(id)[Events.DIRTY]).toBe(0);
});
it('merges attendees by address and keeps the user\'s RSVP', async () => {
const invite = single({
organizerCalendarAddress: 'mailto:boss@example.net',
participants: {
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
},
} as Partial<CalendarEventWire>);
const { h, id } = await synced(invite);
h.fake.user.setAttendeeStatus(id, ME, 1);
const remote = { ...invite, participants: { ...invite.participants, new: { calendarAddress: 'mailto:new@example.net', roles: { attendee: true } } } } as CalendarEventWire;
const plan = await h.download(remote);
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
const rows = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [id]);
expect(rows.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_STATUS]])).toEqual([
['boss@example.net', 3],
[ME, 1],
['new@example.net', 3],
]);
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))[0]).toMatchObject({
patch: { 'participants/me/participationStatus': 'accepted' },
sendSchedulingMessages: true,
});
});
it('destroys an event deleted on the device, with a CANCEL when others take part', async () => {
const { h, id } = await synced(weekly());
h.fake.user.deleteEvent(id);
expect(actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))).toEqual([
{ kind: 'destroy', id: 'bl', uid: 'probe-rec@example.org', sendSchedulingMessages: true },
]);
// Its exceptions go with it.
const local = (await h.local('bl'))!;
const group = calendarPlanner.planLocalDelete(local);
await h.apply(group);
expect(await h.events()).toEqual([]);
});
it('removes only the synced membership of an event that is in several calendars', async () => {
const h = await new Harness().setup(['b', 'w', 'r']);
const isSelected = h.ctx.isSelected;
h.ctx = { ...h.ctx, isSelected: (key) => key !== `${ACCT}/x` && isSelected(key) };
await h.download(single({ calendarIds: { b: true, x: true } }));
h.fake.user.deleteEvent(Number(h.masterRow('e1')._id));
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null } }]);
// The server keeps it elsewhere: the rows go.
const accepted = calendarPlanner.planAccepted((await h.local('e1'))!, single({ calendarIds: { x: true } }), h.ctx);
await h.apply(accepted.ops);
expect(await h.events()).toEqual([]);
});
it('destroys a deleted event whose create had an unknown outcome by its uid', async () => {
const h = await new Harness().setup();
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, { [Events.TITLE]: 'x', [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 });
const { local } = await claimAndCreate(h, id);
h.fake.user.deleteEvent(id);
// With the pending _SYNC_ID the app delete was a soft delete the engine can act on.
expect(h.row(id)).toMatchObject({ [Events.DELETED]: 1 });
expect(actionsOf(calendarPlanner.planUpload(await h.byRow(id), h.ctx))).toEqual([{ kind: 'destroy', id: null, uid: local.pending!.uid }]);
});
it('keeps a device deletion when the server edited the event meanwhile', async () => {
const { h, id } = await synced(single());
h.fake.user.deleteEvent(id);
const plan = await h.download(single({ title: 'Server edit' }));
expect(plan).toMatchObject({ stillDirty: true, effect: 'update', writes: 1 });
expect(h.row(id)).toMatchObject({ [Events.DELETED]: 1, [Events.TITLE]: 'Lunch' });
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))[0].kind).toBe('destroy');
});
it('purges an exception row whose master is gone instead of uploading it', async () => {
const { h } = await synced(weekly());
const local = (await h.local('bl'))!;
await h.apply({ ref: 'x', ops: [{ op: 'delete', table: 'events', id: local.eventId, expectCount: 1 }] });
const [orphan] = await h.events();
expect(orphan.cells[Events.ORIGINAL_SYNC_ID]).toBe(`${ACCT}/bl`);
const plan = calendarPlanner.planUpload(orphan, h.ctx);
expect(plan.kind).toBe('purge');
if (plan.kind === 'purge') await h.apply(plan.ops);
expect(await h.events()).toEqual([]);
});
it('refuses tasks everywhere', async () => {
const { h } = await synced(single());
const local = (await h.local('e1'))!;
const task = { ...single(), '@type': 'Task' } as CalendarEventWire;
expect(() => calendarPlanner.planAccepted(local, task, h.ctx)).toThrow(/tasks/);
expect(() => calendarPlanner.planUpload({ ...local, shadow: task }, h.ctx)).toThrow(/tasks/);
});
});
describe('calendar planner: pairs', () => {
it('uploads Etar\'s move to another calendar (delete + insert) as a calendarIds swap', async () => {
const { h, id } = await synced(single());
h.fake.user.deleteEvent(id);
const moved = h.fake.user.insertEvent(h.rowIds.get('w')!, {
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
});
const events = await h.events();
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx);
expect(pairs).toHaveLength(1);
expect(pairs[0].actions).toEqual([{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]);
await h.apply(pairs[0].ops);
const after = await h.events();
expect(after).toHaveLength(1);
expect(after[0]).toMatchObject({ eventId: moved, syncId: `${ACCT}/e1`, dirty: false });
expect(after[0].shadow?.calendarIds).toEqual({ w: true });
// The server's echo writes nothing.
expect(calendarPlanner.planDownload(single({ calendarIds: { w: true } }), after[0], h.ctx).effect).toBe('none');
});
it('uploads a series turned into a single event by delete + insert as a rule removal', async () => {
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
h.fake.user.deleteEvent(id);
h.fake.user.insertEvent(h.rowIds.get('b')!, {
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
});
const events = await h.events();
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx);
expect(pairs.map((p) => p.actions)).toEqual([[{ kind: 'update', id: 'e1', patch: { recurrenceRule: null } }]]);
});
it('does not pair unrelated rows', async () => {
const { h, id } = await synced(single());
h.fake.user.deleteEvent(id);
h.fake.user.insertEvent(h.rowIds.get('w')!, { [Events.TITLE]: 'Something else', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1 });
const events = await h.events();
expect(calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx)).toEqual([]);
});
});
describe('calendar planner: baselines, zones and reminder owners', () => {
it('heals a clean row\'s baseline after provider normalisation, but never a series\' timing or rule', async () => {
const { h, id } = await synced(weekly());
// Provider-side changes without DIRTY (as CONTENT_EXCEPTION_URI's split does to the rule).
const row = h.fake.table('events').get(id)!;
row[Events.ORGANIZER] = 'someone@example.net';
row[Events.RRULE] = 'FREQ=WEEKLY;UNTIL=20261019T125959Z;BYDAY=MO,WE';
const heal = calendarPlanner.planBaselineHeal((await h.local('bl'))!)!;
expect(heal).not.toBeNull();
await h.apply(heal);
const baseline = (await h.local('bl'))!.baseline!;
expect(baseline.cells[Events.ORGANIZER]).toBe('someone@example.net');
expect(baseline.cells[Events.RRULE]).toBe('FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE');
expect(calendarPlanner.planBaselineHeal((await h.local('bl'))!)).toBeNull();
});
it('does not heal a row that turned dirty', async () => {
const { h, id } = await synced(single());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'x' });
expect(calendarPlanner.planBaselineHeal((await h.local('e1'))!)).toBeNull();
});
it('rewrites floating events for a new device zone, keeping their wall time', async () => {
const { h, id } = await synced(single({ id: 'f', timeZone: null, start: '2026-10-06T14:00:00' }));
expect(h.row(id)[Events.DTSTART]).toBe(utc('2026-10-06T12:00:00Z'));
h.ctx = { ...h.ctx, deviceZone: 'America/New_York' };
const group = calendarPlanner.planZoneChange((await h.local('f'))!, 'Europe/Berlin', h.ctx)!;
await h.apply(group);
expect(h.row(id)).toMatchObject({ [Events.DTSTART]: utc('2026-10-06T18:00:00Z'), [Events.EVENT_TIMEZONE]: 'America/New_York' });
expect(calendarPlanner.planZoneChange((await h.local('f'))!, 'Europe/Berlin', h.ctx)).toBeNull();
// Events with a zone are not floating.
const t = await synced(single());
expect(calendarPlanner.planZoneChange((await t.h.local('e1'))!, 'Europe/Berlin', { ...t.h.ctx, deviceZone: 'Asia/Tokyo' })).toBeNull();
});
it('rewrites Reminders rows when the reminder owner changes, without server writes', async () => {
const { h, id } = await synced(weekly());
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id])).toHaveLength(1);
h.ctx = { ...h.ctx, reminderOwner: 'bulwark' };
await h.apply(calendarPlanner.planReminderOwnerChange((await h.local('bl'))!, h.ctx)!);
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id])).toHaveLength(0);
expect(calendarPlanner.planReminderOwnerChange((await h.local('bl'))!, h.ctx)).toBeNull();
h.ctx = { ...h.ctx, reminderOwner: 'device' };
await h.apply(calendarPlanner.planReminderOwnerChange((await h.local('bl'))!, h.ctx)!);
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id]).map((r) => r[Reminders.MINUTES])).toEqual([15]);
// Back with the calendar app: an echo download writes nothing.
expect(calendarPlanner.planDownload(weekly(), await h.local('bl'), h.ctx).effect).toBe('none');
});
});
describe('calendar planner: decoding', () => {
it('tolerates garbage in sync columns and numbers read back as text', async () => {
const [event] = calendarPlanner.decodeEvents(
[
{
_id: '5', calendar_id: '1', _sync_id: 'c/x', dirty: '1', deleted: '0', sync_data1: '{broken', sync_data3: '{"uid":1}',
sync_data4: '[]', sync_data5: 'nope', title: 'T', allDay: '0', dtstart: '1000', rrule: null, original_id: null, original_sync_id: null,
},
],
[],
[],
);
expect(event).toMatchObject({ eventId: 5, calendarRowId: 1, syncId: 'c/x', dirty: true, deleted: false, shadow: null, pending: null, baseline: null, poison: null });
});
it('groups exceptions by ORIGINAL_ID, else ORIGINAL_SYNC_ID, and returns orphans as masters', () => {
const events = calendarPlanner.decodeEvents(
[
{ _id: 1, calendar_id: 1, _sync_id: 'c/m', eventTimezone: 'Europe/Berlin', allDay: 0, dirty: 0, deleted: 0 },
{ _id: 2, calendar_id: 1, _sync_id: null, original_id: 1, originalInstanceTime: utc('2026-10-07T07:00:00Z'), dirty: 1, deleted: 0 },
{ _id: 3, calendar_id: 1, _sync_id: 'c/m#2026-10-08T09:00:00', original_sync_id: 'c/m', sync_data2: '2026-10-08T09:00:00', dirty: 0, deleted: 0 },
{ _id: 4, calendar_id: 1, _sync_id: null, original_sync_id: 'c/gone', originalInstanceTime: 0, dirty: 1, deleted: 0 },
],
[],
[],
);
expect(events.map((e) => [e.eventId, e.exceptions.map((x) => [x.eventId, (x as { recurrenceId: string }).recurrenceId])])).toEqual([
[1, [[2, '2026-10-07T09:00:00'], [3, '2026-10-08T09:00:00']]],
[4, []],
]);
});
it('marks two masters sharing a _SYNC_ID as a split source and clone', () => {
const events = calendarPlanner.decodeEvents(
[
{ _id: 1, calendar_id: 1, _sync_id: 'c/m' },
{ _id: 9, calendar_id: 1, _sync_id: 'c/m' },
{ _id: 3, calendar_id: 1, _sync_id: 'c/n' },
],
[],
[],
);
expect(events.map((e: LocalEvent) => [e.eventId, e.split])).toEqual([
[1, 'source'],
[3, undefined],
[9, 'clone'],
]);
});
});
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { Attendees, Events, Reminders } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { Harness, single, utc, weekly } from './harness';
describe('calendar planner: what the real providers hand back', () => {
it('sees no edit in numbers read back as text', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const id = Number(h.masterRow('bl')._id);
// Some provider columns come back through the bridge as text.
const row = h.fake.table('events').get(id)!;
for (const c of [Events.STATUS, Events.AVAILABILITY, Events.ACCESS_LEVEL, Events.DTSTART, Events.ALL_DAY, Events.EVENT_COLOR, Events.CALENDAR_ID]) {
row[c] = String(row[c]);
}
for (const a of h.fake.table('attendees').values()) {
if (a.event_id === id) for (const c of [Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_TYPE, Attendees.ATTENDEE_RELATIONSHIP]) a[c] = String(a[c]);
}
for (const r of h.fake.table('reminders').values()) {
if (r.event_id === id) for (const c of [Reminders.MINUTES, Reminders.METHOD]) r[c] = String(r[c]);
}
expect(calendarPlanner.planDownload(weekly(), await h.local('bl'), h.ctx).effect).toBe('none');
h.fake.user.updateEvent(id, {});
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('clean');
});
it('asserts DIRTY as read, so a row inserted with a NULL DIRTY still takes a guarded write', async () => {
const h = await new Harness().setup();
await h.download(single());
const id = Number(h.masterRow('e1')._id);
h.fake.table('events').get(id)![Events.DIRTY] = null;
const local = (await h.local('e1'))!;
expect(local.dirty).toBe(false);
const plan = calendarPlanner.planDownload(single({ title: 'Changed' }), local, h.ctx);
const assert = plan.ops.ops.find((op) => op.op === 'assert' && 'id' in op && op.id === id) as { values: Record<string, unknown> };
expect(assert.values[Events.DIRTY]).toBeNull();
await h.apply(plan.ops);
expect(h.row(id)[Events.TITLE]).toBe('Changed');
});
it('writes DIRTY = 0 on every row it inserts', async () => {
const h = await new Harness().setup();
const plan = calendarPlanner.planDownload(weekly(), null, h.ctx);
const inserts = plan.ops.ops.filter((op) => op.op === 'insert' && op.table === 'events') as Array<{ values: Record<string, unknown> }>;
expect(inserts).toHaveLength(2);
for (const insert of inserts) expect(insert.values[Events.DIRTY]).toBe(0);
});
it('addresses attendee and reminder rows by id only', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const plan = calendarPlanner.planDownload(
{ ...weekly(), participants: { me1: weekly().participants!.me1 }, alerts: {} },
await h.local('bl'),
h.ctx,
);
for (const op of plan.ops.ops) {
if ((op.op === 'update' || op.op === 'delete') && (op.table === 'attendees' || op.table === 'reminders')) {
expect(op).toMatchObject({ expectCount: 1 });
expect(typeof op.id).toBe('number');
expect(op.where).toBeUndefined();
}
if ('where' in op && op.where) expect(op.where).not.toMatch(/\b_id\b/);
}
await h.apply(plan.ops);
});
it('computes instance times exactly across a DST change of the series\' zone', async () => {
const h = await new Harness().setup();
const event = weekly();
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }] };
event.recurrenceOverrides = { '2026-11-02T09:00:00': { title: 'After the change' } };
await h.download(event);
const [x] = (await h.local('bl'))!.exceptions;
// New York left DST on 1 November: 09:00 is 14:00Z from then on, not 13:00Z.
expect(x.cells[Events.ORIGINAL_INSTANCE_TIME]).toBe(utc('2026-11-02T14:00:00Z'));
expect(x.cells[Events.DTSTART]).toBe(utc('2026-11-02T14:00:00Z'));
});
it('derives the recurrence id of an app\'s exception from its instance time in the master\'s zone', async () => {
const h = await new Harness().setup();
const event = weekly();
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }] };
event.recurrenceOverrides = {};
await h.download(event);
const id = Number(h.masterRow('bl')._id);
h.fake.user.insertException(id, utc('2026-11-02T14:00:00Z'), { [Events.TITLE]: 'x', [Events.DTSTART]: utc('2026-11-02T14:00:00Z'), [Events.DTEND]: utc('2026-11-02T15:30:00Z') });
expect((await h.local('bl'))!.exceptions[0].recurrenceId).toBe('2026-11-02T09:00:00');
});
it('ignores an EXRULE or RDATE an app wrote', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const id = Number(h.masterRow('bl')._id);
h.fake.user.updateEvent(id, { [Events.EXRULE]: 'FREQ=MONTHLY' });
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('clean');
});
});
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import type { RecurrenceRule } from '../../../api/types';
import {
canonicalRRule,
isRuleRepresentable,
parseUntil,
ruleToRRule,
rruleToRule,
unrepresentableParts,
untilUtc,
} from '../../calendar/rrule';
import { aospRruleValid } from '../fakes/fake-provider';
import { exdateSet, formatExdate, parseExdate } from '../../calendar/exdate';
const timed = { allDay: false, zone: 'Europe/Berlin' };
const allDay = { allDay: true, zone: 'UTC' };
describe('calendar/rrule', () => {
it.each<[string, RecurrenceRule, string]>([
['daily', { frequency: 'daily' }, 'FREQ=DAILY'],
['interval', { frequency: 'weekly', interval: 2 }, 'FREQ=WEEKLY;INTERVAL=2'],
['count', { frequency: 'daily', count: 10 }, 'FREQ=DAILY;COUNT=10'],
['byDay', { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] }, 'FREQ=WEEKLY;BYDAY=MO,WE'],
['nth weekday', { frequency: 'monthly', byDay: [{ day: 'fr', nthOfPeriod: -1 }, { day: 'tu', nthOfPeriod: 2 }] }, 'FREQ=MONTHLY;BYDAY=-1FR,2TU'],
['byMonthDay', { frequency: 'monthly', byMonthDay: [1, 15, -1] }, 'FREQ=MONTHLY;BYMONTHDAY=1,15,-1'],
['byMonth', { frequency: 'yearly', byMonth: ['3', '10'] }, 'FREQ=YEARLY;BYMONTH=3,10'],
['byYearDay', { frequency: 'yearly', byYearDay: [1, -1] }, 'FREQ=YEARLY;BYYEARDAY=1,-1'],
['byWeekNo', { frequency: 'yearly', byWeekNo: [20], byDay: [{ day: 'mo' }] }, 'FREQ=YEARLY;BYDAY=MO;BYWEEKNO=20'],
['byHour/Minute/Second', { frequency: 'daily', byHour: [9, 17], byMinute: [0, 30], bySecond: [0] }, 'FREQ=DAILY;BYSECOND=0;BYMINUTE=0,30;BYHOUR=9,17'],
['bySetPosition', { frequency: 'monthly', byDay: [{ day: 'mo' }, { day: 'tu' }], bySetPosition: [-1] }, 'FREQ=MONTHLY;BYDAY=MO,TU;BYSETPOS=-1'],
['wkst', { frequency: 'weekly', interval: 2, firstDayOfWeek: 'su' }, 'FREQ=WEEKLY;INTERVAL=2;WKST=SU'],
])('writes and reads back %s', (_name, rule, rrule) => {
const written = ruleToRRule(rule, timed)!;
expect(written).toBe(rrule);
expect(aospRruleValid(written)).toBe(true);
expect(rruleToRule(written, timed)).toEqual(rule);
});
it('leaves out the defaults: INTERVAL=1, WKST=MO, rscale gregorian, skip omit', () => {
expect(ruleToRRule({ frequency: 'weekly', interval: 1, firstDayOfWeek: 'mo', rscale: 'gregorian', skip: 'omit' }, timed)).toBe('FREQ=WEEKLY');
});
it('writes UNTIL as UTC for timed rules, converted from the event zone, and as a date for all-day rules', () => {
expect(ruleToRRule({ frequency: 'daily', until: '2026-10-30T09:00:00' }, timed)).toBe('FREQ=DAILY;UNTIL=20261030T080000Z');
expect(ruleToRRule({ frequency: 'daily', until: '2026-07-30T09:00:00' }, timed)).toBe('FREQ=DAILY;UNTIL=20260730T070000Z');
expect(ruleToRRule({ frequency: 'daily', until: '2026-10-30T23:59:59' }, allDay)).toBe('FREQ=DAILY;UNTIL=20261030');
expect(rruleToRule('FREQ=DAILY;UNTIL=20261030T080000Z', timed)).toEqual({ frequency: 'daily', until: '2026-10-30T09:00:00' });
expect(rruleToRule('FREQ=DAILY;UNTIL=20261030', allDay)).toEqual({ frequency: 'daily', until: '2026-10-30T00:00:00' });
});
it('converts UNTIL across DST edges', () => {
// New York spring gap: 02:30 does not exist, UNTIL moves forward like the instance does.
expect(ruleToRRule({ frequency: 'daily', until: '2026-03-08T02:30:00' }, { allDay: false, zone: 'America/New_York' })).toBe(
'FREQ=DAILY;UNTIL=20260308T073000Z',
);
// Berlin autumn overlap: the first instant.
expect(ruleToRRule({ frequency: 'daily', until: '2026-10-25T02:30:00' }, timed)).toBe('FREQ=DAILY;UNTIL=20261025T003000Z');
expect(untilUtc({ frequency: 'daily', until: '2026-10-25T02:30:00' }, timed)).toBe(Date.parse('2026-10-25T00:30:00Z'));
// Sydney's autumn overlap.
expect(ruleToRRule({ frequency: 'weekly', until: '2026-04-05T02:30:00' }, { allDay: false, zone: 'Australia/Sydney' })).toBe(
'FREQ=WEEKLY;UNTIL=20260404T153000Z',
);
});
it('reads rules the way device apps write them', () => {
expect(rruleToRule('rrule:freq=weekly;byday=mo,we;x-fossify=1', timed)).toEqual({ frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] });
// Etar writes all-day UNTIL as a UTC date-time (the instance start minus 1 s).
expect(rruleToRule('FREQ=DAILY;UNTIL=20261015T235959Z', allDay)).toEqual({ frequency: 'daily', until: '2026-10-15T00:00:00' });
// A floating UNTIL is UTC to AOSP, and so is a DATE on a timed rule.
expect(parseUntil('20261030T080000', timed)).toBe('2026-10-30T09:00:00');
expect(parseUntil('20261030', timed)).toBe('2026-10-30T01:00:00');
// COUNT wins over UNTIL next to it.
expect(rruleToRule('FREQ=DAILY;COUNT=5;UNTIL=20261030T080000Z', timed)).toEqual({ frequency: 'daily', count: 5 });
// Several rules: the first one.
expect(rruleToRule('FREQ=DAILY\nFREQ=WEEKLY', timed)).toEqual({ frequency: 'daily' });
expect(rruleToRule('INTERVAL=2', timed)).toBeNull();
expect(rruleToRule('FREQ=FORTNIGHTLY', timed)).toBeNull();
expect(rruleToRule(null, timed)).toBeNull();
});
it('tells rules AOSP cannot hold', () => {
expect(unrepresentableParts({ frequency: 'yearly', rscale: 'chinese' })).toEqual(['rscale']);
expect(unrepresentableParts({ frequency: 'monthly', skip: 'forward' })).toEqual(['skip']);
expect(unrepresentableParts({ frequency: 'yearly', byMonth: ['5L'] })).toEqual(['byMonth']);
expect(isRuleRepresentable({ frequency: 'yearly', rscale: 'GREGORIAN', skip: 'omit' })).toBe(true);
// Never RSCALE or SKIP; a leap month is dropped, not written.
const written = ruleToRRule({ frequency: 'yearly', rscale: 'chinese', skip: 'forward', byMonth: ['5L', '6'] }, timed)!;
expect(written).toBe('FREQ=YEARLY;BYMONTH=6');
expect(aospRruleValid(written)).toBe(true);
});
it('compares rules by meaning', () => {
expect(canonicalRRule('FREQ=WEEKLY;BYDAY=WE,MO;INTERVAL=1;WKST=MO')).toBe(canonicalRRule('freq=weekly;byday=mo,we'));
expect(canonicalRRule('FREQ=WEEKLY;BYDAY=MO')).not.toBe(canonicalRRule('FREQ=WEEKLY;BYDAY=TU'));
expect(canonicalRRule(null)).toBe('');
});
});
describe('calendar/exdate', () => {
it('writes excluded instances as UTC date-times, or dates for all-day events', () => {
expect(formatExdate(['2026-10-14T09:00:00', '2026-10-12T09:00:00'], timed)).toBe('20261012T070000Z,20261014T070000Z');
expect(formatExdate(['2026-10-12T00:00:00'], allDay)).toBe('20261012');
expect(formatExdate([], timed)).toBeNull();
});
it('reads every form back into recurrence ids in the event zone', () => {
expect(parseExdate('20261012T070000Z,20261014T070000Z', timed)).toEqual(['2026-10-12T09:00:00', '2026-10-14T09:00:00']);
// A zone prefix, a floating time without one (UTC), several lines.
expect(parseExdate('Europe/Berlin;20261012T090000\n20261014T070000', timed)).toEqual(['2026-10-12T09:00:00', '2026-10-14T09:00:00']);
expect(parseExdate('20261012,20261013T000000Z', allDay)).toEqual(['2026-10-12T00:00:00', '2026-10-13T00:00:00']);
expect(parseExdate('garbage,20261012T070000Z', timed)).toEqual(['2026-10-12T09:00:00']);
expect(parseExdate(null, timed)).toEqual([]);
});
it('compares columns by the instants they name', () => {
expect([...exdateSet('Europe/Berlin;20261012T090000')]).toEqual(['20261012T070000Z']);
expect([...exdateSet('20261012T000000Z', true)]).toEqual(['20261012']);
});
});
@@ -0,0 +1,320 @@
import { describe, expect, it } from 'vitest';
import { Attendees, Events, Reminders } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import type { UploadPlan } from '../../planner';
import type { CalendarEventWire } from '../../wire';
import type { Row } from '../../types';
import { cssColorToArgb } from '../../calendar/values';
import { Harness, ME, serverApply, single, utc, weekly } from './harness';
type Plan = UploadPlan<CalendarEventWire>;
function updateOf(plan: Plan) {
expect(plan.kind).toBe('upload');
if (plan.kind !== 'upload') throw new Error('not an upload');
expect(plan.actions).toHaveLength(1);
const action = plan.actions[0];
if (action.kind !== 'update') throw new Error(`not an update: ${action.kind}`);
return action;
}
async function synced(event: CalendarEventWire, options = {}) {
const h = await new Harness(options).setup();
await h.download(event);
const id = Number(h.masterRow(event.id)._id);
return { h, id };
}
const FIXTURES: Array<[string, () => CalendarEventWire]> = [
['a recurring event with overrides', weekly],
['a single timed event', () => single()],
['a floating event', () => single({ id: 'f', timeZone: null })],
['an all-day event', () => single({ id: 'a', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P3D' })],
['a recurring all-day event', () => single({ id: 'y', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D', recurrenceRule: { frequency: 'yearly' } })],
['an HTML description and a CSS colour name', () => single({ id: 'h', description: '<p>Hi <b>there</b></p>', descriptionContentType: 'text/html', color: 'rebeccapurple' })],
];
describe('calendar planner: uploads of edited events', () => {
it.each(FIXTURES)('round-trips %s: rows untouched → clean', async (_name, make) => {
const event = make();
const { h, id } = await synced(event);
// An app saving the event without changing anything.
h.fake.user.updateEvent(id, {});
const plan = calendarPlanner.planUpload((await h.local(event.id))!, h.ctx);
expect(plan.kind).toBe('clean');
if (plan.kind !== 'clean') return;
await h.apply(plan.ops);
expect(h.row(id)[Events.DIRTY]).toBe(0);
// And the next download of the same event writes nothing.
expect(calendarPlanner.planDownload(event, await h.local(event.id), h.ctx).effect).toBe('none');
});
it.each<[string, Row, Record<string, unknown>]>([
['title', { [Events.TITLE]: 'New title' }, { title: 'New title' }],
['description', { [Events.DESCRIPTION]: 'New text' }, { description: 'New text' }],
['a cleared description', { [Events.DESCRIPTION]: '' }, { description: null }],
['location', { [Events.EVENT_LOCATION]: 'Room 2' }, { 'locations/locA/name': 'Room 2' }],
['a cleared location', { [Events.EVENT_LOCATION]: null }, { 'locations/locA': null }],
['status', { [Events.STATUS]: 1 }, { status: 'confirmed' }],
['availability', { [Events.AVAILABILITY]: 0 }, { freeBusyStatus: 'busy' }],
['tentative availability (busy)', { [Events.AVAILABILITY]: 2 }, { freeBusyStatus: 'busy' }],
['privacy', { [Events.ACCESS_LEVEL]: 3 }, { privacy: 'public' }],
['default privacy', { [Events.ACCESS_LEVEL]: 0 }, { privacy: null }],
['colour', { [Events.EVENT_COLOR]: cssColorToArgb('#ff8800') }, { color: '#ff8800' }],
['duration', { [Events.DURATION]: 'P7200S' }, { duration: 'PT2H' }],
])('uploads one edited field of a series as a patch of only that field: %s', async (_name, values, patch) => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, values);
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.id).toBe('bl');
expect(action.patch).toEqual(patch);
});
it('adds the first location as a whole map', async () => {
const { h, id } = await synced(single());
h.fake.user.updateEvent(id, { [Events.EVENT_LOCATION]: 'Café' });
const action = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
const [[key, value]] = Object.entries(action.patch.locations as Record<string, unknown>);
expect(Object.keys(action.patch)).toEqual(['locations']);
expect(key).toMatch(/^b[0-9a-z]{8}$/);
expect(value).toEqual({ '@type': 'Location', name: 'Café' });
});
it('uploads an HTML description as text/plain only when it was edited', async () => {
const { h, id } = await synced(single({ description: '<p>Hi <b>there</b></p>', descriptionContentType: 'text/html' }));
expect(h.row(id)[Events.DESCRIPTION]).toBe('Hi there');
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'Hi there!' });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({
description: 'Hi there!',
descriptionContentType: 'text/plain',
});
});
it('never uploads an edit of a description that was written truncated', async () => {
const long = 'x'.repeat(70_000);
const { h, id } = await synced(single({ description: long }));
expect(String(h.row(id)[Events.DESCRIPTION]).length).toBe(65_536);
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'short now', [Events.TITLE]: 'T' });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ title: 'T' });
});
it('moves a single event: start in its zone, the length unchanged', async () => {
const { h, id } = await synced(single());
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T12:30:00Z'), [Events.DTEND]: utc('2026-10-06T13:30:00Z') });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ start: '2026-10-06T14:30:00' });
});
it('keeps a floating event floating, and takes a zone the user picked', async () => {
const { h, id } = await synced(single({ id: 'f', timeZone: null }));
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z'), [Events.DTEND]: utc('2026-10-06T12:00:00Z') });
expect(updateOf(calendarPlanner.planUpload((await h.local('f'))!, h.ctx)).patch).toEqual({ start: '2026-10-06T13:00:00' });
h.fake.user.updateEvent(id, { [Events.EVENT_TIMEZONE]: 'Asia/Tokyo' });
expect(updateOf(calendarPlanner.planUpload((await h.local('f'))!, h.ctx)).patch).toEqual({ start: '2026-10-06T20:00:00', timeZone: 'Asia/Tokyo' });
});
it('keeps all-day events dates and turns timed ones into all-day ones', async () => {
const { h, id } = await synced(single({ id: 'a', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D' }));
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-09T00:00:00Z'), [Events.DTEND]: utc('2026-10-11T00:00:00Z') });
expect(updateOf(calendarPlanner.planUpload((await h.local('a'))!, h.ctx)).patch).toEqual({ start: '2026-10-09T00:00:00', duration: 'P2D' });
const t = await synced(single());
t.h.fake.user.updateEvent(t.id, {
[Events.ALL_DAY]: 1,
[Events.EVENT_TIMEZONE]: 'UTC',
[Events.DTSTART]: utc('2026-10-06T00:00:00Z'),
[Events.DTEND]: utc('2026-10-07T00:00:00Z'),
});
expect(updateOf(calendarPlanner.planUpload((await t.h.local('e1'))!, t.h.ctx)).patch).toEqual({
start: '2026-10-06T00:00:00',
duration: 'P1D',
timeZone: null,
showWithoutTime: true,
});
});
it('sends a single event whose new start is a repeated wall time in a fixed-offset zone', async () => {
const { h, id } = await synced(single());
// 02:30 CEST on the night the clocks go back: Stalwart would drop it in Europe/Berlin.
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T00:30:00Z'), [Events.DTEND]: utc('2026-10-25T01:30:00Z') });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ start: '2026-10-25T02:30:00', timeZone: 'Etc/GMT-2' });
// The second 02:30 (CET) is the same wall time an hour later.
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T01:30:00Z'), [Events.DTEND]: utc('2026-10-25T02:30:00Z') });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ start: '2026-10-25T02:30:00', timeZone: 'Etc/GMT-1' });
});
it('skips a series whose new start is a repeated wall time', async () => {
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T00:30:00Z') });
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'dstAmbiguous' });
});
it('never uploads the timing of a rule Android cannot represent', async () => {
const event = single({ recurrenceRule: { frequency: 'yearly', rscale: 'chinese', byMonth: ['5L'] } });
const { h, id } = await synced(event);
expect(h.row(id)[Events.RRULE]).toBe('FREQ=YEARLY');
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z') });
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'ruleNotRepresentable' });
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Festival' });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ title: 'Festival' });
});
it('uploads the user\'s RSVP with scheduling messages', async () => {
const event = single({
organizerCalendarAddress: 'mailto:boss@example.net',
participants: {
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
},
} as Partial<CalendarEventWire>);
const { h, id } = await synced(event);
h.fake.user.setAttendeeStatus(id, ME, 1);
const action = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
expect(action).toMatchObject({ patch: { 'participants/me/participationStatus': 'accepted' }, sendSchedulingMessages: true });
});
it('adds and removes attendees; the organizer notifies them', async () => {
const { h, id } = await synced(weekly());
const guest = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ? AND ${Attendees.ATTENDEE_EMAIL} = ?`, [id, 'guest@example.net'])[0];
h.fake.user.removeAttendee(Number(guest._id));
h.fake.user.addAttendee(id, {
[Attendees.ATTENDEE_EMAIL]: 'new@example.net',
[Attendees.ATTENDEE_NAME]: 'New',
[Attendees.ATTENDEE_TYPE]: 2,
[Attendees.ATTENDEE_RELATIONSHIP]: 1,
[Attendees.ATTENDEE_STATUS]: 3,
});
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.sendSchedulingMessages).toBe(true);
const added = Object.entries(action.patch).find(([k, v]) => k.startsWith('participants/') && v);
expect(added![1]).toEqual({
'@type': 'Participant',
calendarAddress: 'mailto:new@example.net',
name: 'New',
roles: { attendee: true, optional: true },
participationStatus: 'needs-action',
expectReply: true,
});
expect(action.patch['participants/guestX']).toBeNull();
});
it('sends the whole participants map for the first attendee', async () => {
const { h, id } = await synced(single());
h.fake.user.addAttendee(id, { [Attendees.ATTENDEE_EMAIL]: 'guest@example.net', [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 3 });
const action = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
expect(Object.keys(action.patch)).toEqual(['participants']);
expect(Object.values(action.patch.participants as object)).toHaveLength(1);
expect(action.sendSchedulingMessages).toBe(true);
});
it('replaces the reminders it can show, keeping the matching keys and the others', async () => {
const { h, id } = await synced(weekly());
h.fake.user.setReminders(id, [{ minutes: 15 }, { minutes: 60, method: 2 }]);
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
const keys = Object.keys(action.patch);
expect(keys).toHaveLength(1);
expect(keys[0]).toMatch(/^alerts\/b[0-9a-z]{8}$/);
expect(action.patch[keys[0]]).toEqual({ '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' }, action: 'email' });
// Reminders alone notify nobody.
expect(action.sendSchedulingMessages).toBeUndefined();
h.fake.user.setReminders(id, []);
expect(updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({ 'alerts/al1': null });
});
it('switches off the calendar defaults when reminders of an event that used them change', async () => {
const { h, id } = await synced(single({ useDefaultAlerts: true }));
h.fake.user.setReminders(id, [{ minutes: 10 }, { minutes: 30 }]);
const patch = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch;
expect(patch.useDefaultAlerts).toBe(false);
const alerts = patch.alerts as Record<string, { trigger: { offset: string } }>;
// The default's key is kept for the reminder that matches it.
expect(alerts.d1.trigger.offset).toBe('-PT10M');
expect(Object.values(alerts).map((a) => a.trigger.offset).sort()).toEqual(['-PT10M', '-PT30M']);
});
it('does not upload reminders when Bulwark owns them', async () => {
const { h, id } = await synced(weekly(), { reminderOwner: 'bulwark' });
h.fake.user.setReminders(id, [{ minutes: 5 }]);
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('clean');
});
it('ignores attendee types and relationships a lossy editor rewrote, and merges its rule part by part', async () => {
const event = weekly();
event.recurrenceRule = { frequency: 'monthly', byDay: [{ day: 'mo' }, { day: 'tu' }], bySetPosition: [-1], count: 10 };
const { h, id } = await synced(event);
const attendees = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [id]).map((a) => ({
[Attendees.ATTENDEE_EMAIL]: a[Attendees.ATTENDEE_EMAIL],
[Attendees.ATTENDEE_NAME]: a[Attendees.ATTENDEE_NAME],
[Attendees.ATTENDEE_RELATIONSHIP]: 1,
[Attendees.ATTENDEE_TYPE]: 1,
[Attendees.ATTENDEE_STATUS]: a[Attendees.ATTENDEE_STATUS],
}));
const reminders = h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id]).map((r) => ({ minutes: Number(r[Reminders.MINUTES]) }));
// Fossify rebuilds the rule without BYSETPOS and changes the count.
h.fake.user.fossifySaveEvent(id, { [Events.RRULE]: 'FREQ=MONTHLY;COUNT=12;BYDAY=MO,TU' }, attendees, reminders);
h.fake.table('events').get(id)![Events.MUTATORS] = 'org.fossify.calendar';
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.patch).toEqual({
recurrenceRule: { frequency: 'monthly', count: 12, byDay: [{ day: 'mo' }, { day: 'tu' }], bySetPosition: [-1] },
});
});
it('turns a series into a single event in place', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.RRULE]: null, [Events.DURATION]: null, [Events.DTEND]: utc('2026-10-05T14:30:00Z') });
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.patch).toEqual({ recurrenceRule: null, recurrenceOverrides: null });
expect(action.sendSchedulingMessages).toBe(true);
});
it('moves an event between calendars of its account by swapping calendarIds', async () => {
const { h, id } = await synced(single());
h.fake.user.updateEvent(id, { [Events.CALENDAR_ID]: h.rowIds.get('w')! });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ 'calendarIds/b': null, 'calendarIds/w': true });
});
it('reverts edits in a read-only calendar, but lets an RSVP through', async () => {
const event = single({
calendarIds: { r: true },
organizerCalendarAddress: 'mailto:boss@example.net',
participants: {
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
},
} as Partial<CalendarEventWire>);
const { h, id } = await synced(event);
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Mine now' });
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
expect(plan.kind).toBe('revert');
if (plan.kind === 'revert') await h.apply(plan.ops);
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Lunch', [Events.DIRTY]: 0 });
h.fake.user.setAttendeeStatus(id, ME, 2);
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ 'participants/me/participationStatus': 'declined' });
});
it('skips a poisoned item until it changes or the back-off ends', async () => {
const { h, id } = await synced(single());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Bad' });
const local = (await h.local('e1'))!;
const { poisonFingerprint, poisonOps } = await import('../../calendar/planner');
const { nextMarker } = await import('../../engine/poison');
// The marker as the engine writes it after an invalidProperties SetError.
const marker = nextMarker(null, poisonFingerprint(local), { type: 'invalidProperties' }, h.ctx.now, { firstMs: 3_600_000, maxMs: 86_400_000 });
await h.apply(poisonOps(local, marker));
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'invalidProperties' });
expect(calendarPlanner.planUpload((await h.local('e1'))!, { ...h.ctx, now: h.ctx.now + 7_200_000 }).kind).toBe('upload');
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Better' });
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx).kind).toBe('upload');
});
it('applies a patch the server accepts (round trip through serverApply)', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Renamed', [Events.EVENT_LOCATION]: 'Hall' });
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
const server = serverApply(weekly(), action.patch);
expect(server.title).toBe('Renamed');
expect(server.locations).toEqual({ locA: { name: 'Hall', '@type': 'Location' } });
expect(server.participants).toEqual(weekly().participants);
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import {
canonicalZone,
classifyLocal,
dateToUtcMidnight,
fixedOffsetZone,
isValidTimeZone,
localToUtc,
offsetAt,
parseLocalDateTime,
utcMidnightToDate,
utcToLocal,
} from '../../calendar/zoned-time';
const utc = (iso: string) => Date.parse(iso);
describe('calendar/zoned-time', () => {
it('reads LocalDateTimes like Stalwart: fraction and suffix ignored, impossible dates refused', () => {
expect(parseLocalDateTime('2026-10-25T02:30:00')).toEqual({ year: 2026, month: 10, day: 25, hour: 2, minute: 30, second: 0 });
expect(parseLocalDateTime('2026-10-25T02:30:00.250Z')).toMatchObject({ hour: 2, minute: 30, second: 0 });
expect(parseLocalDateTime('2026-10-25')).toMatchObject({ hour: 0, minute: 0 });
expect(parseLocalDateTime('2026-02-30T00:00:00')).toBeNull();
expect(parseLocalDateTime('2026-01-01T24:00:00')).toBeNull();
expect(parseLocalDateTime('garbage')).toBeNull();
});
// R3 B1: the app's localDateTimeToInstant gets these wrong in one direction or the other.
it.each([
['America/New_York', '2026-03-08T02:30:00', 'gap', '2026-03-08T07:30:00Z'],
['America/New_York', '2026-11-01T01:30:00', 'overlap', '2026-11-01T05:30:00Z'],
['Europe/Berlin', '2026-03-29T02:30:00', 'gap', '2026-03-29T01:30:00Z'],
['Europe/Berlin', '2026-10-25T02:30:00', 'overlap', '2026-10-25T00:30:00Z'],
['Australia/Sydney', '2026-04-05T02:30:00', 'overlap', '2026-04-04T15:30:00Z'],
['Australia/Sydney', '2026-10-04T02:30:00', 'gap', '2026-10-03T16:30:00Z'],
['Australia/Lord_Howe', '2026-10-04T02:15:00', 'gap', '2026-10-03T15:45:00Z'],
['Europe/Berlin', '2026-10-25T04:30:00', 'ok', '2026-10-25T03:30:00Z'],
['Asia/Kolkata', '2026-06-01T10:00:00', 'ok', '2026-06-01T04:30:00Z'],
])('resolves %s %s (%s) with RFC 5545 semantics', (zone, local, kind, expected) => {
expect(classifyLocal(local, zone)).toBe(kind);
expect(new Date(localToUtc(local, zone)!).toISOString().replace('.000', '')).toBe(expected);
});
it('moves a gap time forward by the gap and keeps the wall time of an overlap', () => {
expect(utcToLocal(localToUtc('2026-03-29T02:30:00', 'Europe/Berlin')!, 'Europe/Berlin')).toBe('2026-03-29T03:30:00');
expect(utcToLocal(localToUtc('2026-03-08T02:30:00', 'America/New_York')!, 'America/New_York')).toBe('2026-03-08T03:30:00');
// Both instants of the repeated hour read back as the same wall time.
expect(utcToLocal(utc('2026-10-25T00:30:00Z'), 'Europe/Berlin')).toBe('2026-10-25T02:30:00');
expect(utcToLocal(utc('2026-10-25T01:30:00Z'), 'Europe/Berlin')).toBe('2026-10-25T02:30:00');
});
it('does not depend on the zone the process runs in', () => {
// The device's own gap day is no special case: fields are parsed, not handed to Date.
expect(localToUtc('2026-03-29T02:30:00', 'UTC')).toBe(utc('2026-03-29T02:30:00Z'));
expect(offsetAt(utc('2026-07-01T00:00:00Z'), 'Europe/Berlin')).toBe(7_200_000);
expect(offsetAt(utc('2026-01-01T00:00:00Z'), 'America/New_York')).toBe(-18_000_000);
});
it('validates zones with Intl and names them canonically', () => {
expect(isValidTimeZone('Europe/Berlin')).toBe(true);
expect(isValidTimeZone('Mars/Olympus_Mons')).toBe(false);
expect(isValidTimeZone('')).toBe(false);
expect(isValidTimeZone(null)).toBe(false);
expect(canonicalZone('europe/berlin')).toBe('Europe/Berlin');
expect(canonicalZone('US/Eastern')).toBe('America/New_York');
});
it('names fixed-offset zones the Etc way, UTC for fractional offsets', () => {
expect(fixedOffsetZone(7_200_000)).toBe('Etc/GMT-2');
expect(fixedOffsetZone(-18_000_000)).toBe('Etc/GMT+5');
expect(fixedOffsetZone(0)).toBe('Etc/UTC');
expect(fixedOffsetZone(19_800_000)).toBe('Etc/UTC');
});
it('maps all-day dates to UTC midnight and back', () => {
expect(dateToUtcMidnight('2026-10-08T00:00:00')).toBe(utc('2026-10-08T00:00:00Z'));
expect(dateToUtcMidnight('2026-10-08T09:00:00')).toBe(utc('2026-10-08T00:00:00Z'));
expect(utcMidnightToDate(utc('2026-10-08T00:00:00Z'))).toBe('2026-10-08T00:00:00');
// Date.UTC would read year 50 as 1950.
expect(new Date(dateToUtcMidnight('0050-01-01T00:00:00')!).toISOString()).toBe('0050-01-01T00:00:00.000Z');
});
});
@@ -98,7 +98,7 @@ const COLUMNS: Record<ProviderTable, Set<string>> = {
data: new Set([...Object.values(Data), GroupMembership.GROUP_SOURCE_ID]),
groups: new Set([...Object.values(Groups), 'auto_add', 'favorites']),
settings: new Set(Object.values(ContactsSettings)),
calendars: new Set([...Object.values(Calendars), ...numbered('cal_sync', 4, 10), 'canPartiallyUpdate']),
calendars: new Set([...Object.values(Calendars), ...numbered('cal_sync', 4, 10)]),
events: new Set([...Object.values(Events), ...numbered('sync_data', 7, 10), 'lastSynced']),
attendees: new Set(Object.values(Attendees)),
reminders: new Set(Object.values(Reminders)),
+2
View File
@@ -231,6 +231,8 @@ export const Calendars = {
MAX_REMINDERS: 'maxReminders',
CAN_ORGANIZER_RESPOND: 'canOrganizerRespond',
CAN_MODIFY_TIME_ZONE: 'canModifyTimeZone',
/** Must stay 0: with 1 the provider keeps a LAST_SYNCED copy of every event an app edits. */
CAN_PARTIALLY_UPDATE: 'canPartiallyUpdate',
IS_PRIMARY: 'isPrimary',
CAL_SYNC1: 'cal_sync1',
CAL_SYNC2: 'cal_sync2',
+90
View File
@@ -0,0 +1,90 @@
/**
* 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 };
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Participants ↔ Attendees rows (docs/device-sync.md, "Other properties").
*
* Only participants with a `mailto:` calendar address become rows; the rest
* stay on the server untouched. Android decides who "me" is by comparing
* ATTENDEE_EMAIL with the calendar's OWNER_ACCOUNT case-sensitively, so the
* user's own address, under whichever alias, is written as exactly
* OWNER_ACCOUNT. Stalwart can list one address twice (the organizer derived
* from ORGANIZER plus its ATTENDEE entry); such entries become one row.
* Participant ids never reach the device: rows are matched to participants
* by address.
*/
import type { Participant } from '../../api/types';
import { AttendeeRelationship, Attendees } from '../android-columns';
import type { Row } from '../types';
import { attendeeTypeToDevice, participationToDevice, relationshipToDevice } from './values';
export interface SelfContext {
/** OWNER_ACCOUNT of our calendars, lowercase, no `mailto:`. */
ownerAccount: string;
/** Every address of the user, lowercase, no `mailto:`. */
selfAddresses: string[];
}
type Participants = Record<string, Participant> | null | undefined;
/** The address of a `mailto:` calendar address (lowercased), else null. */
export function mailtoAddress(p: Participant | null | undefined): string | null {
const addr = p?.calendarAddress;
if (typeof addr !== 'string') return null;
const m = /^mailto:(.+)$/i.exec(addr.trim());
if (!m) return null;
let email = m[1].trim();
try {
email = decodeURIComponent(email);
} catch {
// Keep it as written.
}
return email ? email.toLowerCase() : null;
}
export function isSelfAddress(email: string | null | undefined, ctx: SelfContext): boolean {
if (!email) return false;
const e = email.trim().replace(/^mailto:/i, '').toLowerCase();
return e === ctx.ownerAccount.toLowerCase() || ctx.selfAddresses.some((a) => a.toLowerCase() === e);
}
/** The address as written to ATTENDEE_EMAIL / ORGANIZER: the user's own ones as OWNER_ACCOUNT. */
export function deviceAddress(email: string, ctx: SelfContext): string {
return isSelfAddress(email, ctx) ? ctx.ownerAccount : email;
}
/** A device address as the key rows and participants are matched by (the user's aliases are one key). */
export function addressKey(email: unknown, ctx: SelfContext): string {
const e = String(email ?? '').trim().replace(/^mailto:/i, '').toLowerCase();
return isSelfAddress(e, ctx) ? `self:${ctx.ownerAccount.toLowerCase()}` : e;
}
interface AddressGroup {
key: string;
email: string;
ids: string[];
preferred: string;
}
/**
* Participants with a `mailto:` address grouped by address, in server
* order. The preferred entry of a group is the one that attends (Stalwart's
* organizer entry has no `attendee` role), then one with a status.
*/
export function addressGroups(participants: Participants, ctx: SelfContext): AddressGroup[] {
const groups = new Map<string, AddressGroup>();
for (const [id, p] of Object.entries(participants ?? {})) {
const email = mailtoAddress(p);
if (!email) continue;
const key = addressKey(email, ctx);
const group = groups.get(key);
if (group) group.ids.push(id);
else groups.set(key, { key, email, ids: [id], preferred: id });
}
const list = participants ?? {};
for (const group of groups.values()) {
const score = (id: string) => (list[id]?.roles?.attendee ? 2 : 0) + (list[id]?.participationStatus ? 1 : 0);
group.preferred = group.ids.reduce((best, id) => (score(id) > score(best) ? id : best), group.ids[0]);
}
return [...groups.values()];
}
/** The organizer's address for ORGANIZER: `organizerCalendarAddress`, else an owner participant's. */
export function organizerAddress(
event: { organizerCalendarAddress?: string | null; participants?: Participants },
ctx: SelfContext,
): string | null {
const direct = event.organizerCalendarAddress
? mailtoAddress({ calendarAddress: /^mailto:/i.test(event.organizerCalendarAddress) ? event.organizerCalendarAddress : `mailto:${event.organizerCalendarAddress}` })
: null;
if (direct) return deviceAddress(direct, ctx);
for (const p of Object.values(event.participants ?? {})) {
if (p?.roles?.owner) {
const email = mailtoAddress(p);
if (email) return deviceAddress(email, ctx);
}
}
return null;
}
/** Whether the event has participants that become Attendees rows. */
export function hasMailtoParticipants(participants: Participants): boolean {
return Object.values(participants ?? {}).some((p) => mailtoAddress(p) !== null);
}
/** Attendees rows for the participants, one per address, in server order. */
export function attendeeRows(
event: { organizerCalendarAddress?: string | null; participants?: Participants },
ctx: SelfContext,
): Row[] {
const participants = event.participants ?? {};
const organizer = organizerAddress(event, ctx);
return addressGroups(participants, ctx).map((group) => {
const p = participants[group.preferred];
const email = deviceAddress(group.email, ctx);
const isOrganizer =
group.ids.some((id) => participants[id]?.roles?.owner) || (organizer !== null && organizer.toLowerCase() === email.toLowerCase());
const name = p.name ?? group.ids.map((id) => participants[id]?.name).find((n) => !!n) ?? null;
return {
[Attendees.ATTENDEE_EMAIL]: email,
[Attendees.ATTENDEE_NAME]: name || null,
[Attendees.ATTENDEE_RELATIONSHIP]: relationshipToDevice(isOrganizer),
[Attendees.ATTENDEE_TYPE]: attendeeTypeToDevice(p),
[Attendees.ATTENDEE_STATUS]: participationToDevice(p.participationStatus),
};
});
}
/** The participant id a device address stands for (the group's preferred entry), or null. */
export function participantIdFor(participants: Participants, email: unknown, ctx: SelfContext): string | null {
const key = addressKey(email, ctx);
return addressGroups(participants, ctx).find((g) => g.key === key)?.preferred ?? null;
}
/** Whether an attendee row is the organizer's. */
export function isOrganizerRow(row: Row): boolean {
return Number(row[Attendees.ATTENDEE_RELATIONSHIP]) === AttendeeRelationship.ORGANIZER;
}
/** Whether anyone but the user takes part. */
export function hasOtherParticipants(participants: Participants, ctx: SelfContext): boolean {
return Object.values(participants ?? {}).some((p) => {
const email = mailtoAddress(p);
return email !== null && !isSelfAddress(email, ctx);
});
}
/**
* Whether the user organizes the event: the organizer address or an owner
* participant is theirs. An event without an organizer counts too: once it
* has attendees Stalwart makes the account's first address its organizer.
*/
export function userIsOrganizer(
event: { organizerCalendarAddress?: string | null; participants?: Participants },
ctx: SelfContext,
): boolean {
const organizer = organizerAddress(event, ctx);
return organizer ? isSelfAddress(organizer, ctx) : true;
}
+141
View File
@@ -0,0 +1,141 @@
/**
* JMAP calendars ↔ CalendarContract.Calendars rows (docs/device-sync.md,
* "Calendar rows").
*
* Every column CalendarProvider and the calendar apps need is written:
* SYNC_EVENTS=1 (the default 0 hides every instance), a non-null
* OWNER_ACCOUNT (Etar crashes on NULL), CAN_PARTIALLY_UPDATE=0 (else the
* provider keeps LAST_SYNCED copies), MAX_REMINDERS by who owns reminders.
* VISIBLE is written on insert only: afterwards it is the user's. CAL_SYNC1
* stays empty because the provider sends it as the `feed` extra of the sync
* it requests for such calendars. A row is updated only where it differs.
*/
import { CalendarAccess, Calendars } from '../android-columns';
import type { CalendarContext, LocalCalendar, OpGroup } from '../planner';
import type { Row, WriteRow } from '../types';
import type { CalendarLike } from '../wire';
import { collectionKey, parseCollectionKey } from '../common/ids';
import { deepEqual, parseJsonColumn } from '../common/json';
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
import { colors } from '../../theme/tokens';
import { isSelfAddress } from './attendees';
import { CAN_PARTIALLY_UPDATE } from './columns';
import { cssColorToArgb } from './values';
import { canonicalZone } from './zoned-time';
export interface SelectedCalendar {
jmapAccountId: string;
calendar: CalendarLike;
readOnly: boolean;
/** The JMAP account's name, appended to calendars of shared accounts. */
accountName: string;
}
type CalendarsContext = Omit<CalendarContext, 'jmapAccountId'>;
/** The app's palette colour for a calendar without one (`getCalendarColor` in lib/calendar-utils.ts). */
const PALETTE = [
colors.calendar.blue,
colors.calendar.green,
colors.calendar.purple,
colors.calendar.orange,
colors.calendar.red,
colors.calendar.pink,
colors.calendar.teal,
colors.calendar.indigo,
];
function paletteColor(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0;
return PALETTE[Math.abs(hash) % PALETTE.length];
}
/**
* CALENDAR_ACCESS_LEVEL from `myRights`: write all → OWNER, write own →
* CONTRIBUTOR (Etar's threshold for editing), RSVP → RESPOND, else READ. A
* read-only calendar whose rights allow writing is a subscribed feed: READ.
*/
export function accessLevel(calendar: CalendarLike, readOnly: boolean): { level: number; reason: 'rights' | 'subscription' | null } {
const rights = calendar.myRights;
const writable = !rights || !!rights.mayWriteAll || !!rights.mayWriteOwn;
if (readOnly && writable) return { level: CalendarAccess.READ, reason: 'subscription' };
const level = !rights || rights.mayWriteAll
? CalendarAccess.OWNER
: rights.mayWriteOwn
? CalendarAccess.CONTRIBUTOR
: rights.mayRSVP
? CalendarAccess.RESPOND
: CalendarAccess.READ;
return { level: readOnly && level >= CalendarAccess.CONTRIBUTOR ? CalendarAccess.RESPOND : level, reason: readOnly ? 'rights' : null };
}
/** The columns device sync keeps on a calendar row (VISIBLE aside). */
export function calendarValues(selected: SelectedCalendar, ctx: CalendarsContext): WriteRow {
const { calendar } = selected;
const shared = !!selected.accountName && !isSelfAddress(selected.accountName, ctx);
const name = calendar.name || calendar.id;
const access = accessLevel(calendar, selected.readOnly);
const flags = access.reason ? { readOnly: access.reason } : {};
return {
[Calendars._SYNC_ID]: collectionKey(selected.jmapAccountId, calendar.id),
[Calendars.NAME]: name,
[Calendars.CALENDAR_DISPLAY_NAME]: shared ? `${name} (${selected.accountName})` : name,
[Calendars.CALENDAR_COLOR]: cssColorToArgb(calendar.color) ?? cssColorToArgb(paletteColor(calendar.id))!,
[Calendars.CALENDAR_ACCESS_LEVEL]: access.level,
[Calendars.OWNER_ACCOUNT]: ctx.ownerAccount,
[Calendars.SYNC_EVENTS]: 1,
[Calendars.CALENDAR_TIME_ZONE]: canonicalZone(calendar.timeZone) ?? ctx.deviceZone,
[Calendars.ALLOWED_REMINDERS]: '1,2',
[Calendars.ALLOWED_AVAILABILITY]: '0,1',
[Calendars.ALLOWED_ATTENDEE_TYPES]: '0,1,2,3',
[Calendars.MAX_REMINDERS]: ctx.reminderOwner === 'bulwark' ? 0 : 5,
[Calendars.CAN_ORGANIZER_RESPOND]: 0,
[CAN_PARTIALLY_UPDATE]: 0,
[Calendars.CAL_SYNC2]: JSON.stringify(calendar),
[Calendars.CAL_SYNC3]: JSON.stringify(flags),
};
}
const JSON_COLUMNS = new Set<string>([Calendars.CAL_SYNC2, Calendars.CAL_SYNC3]);
function differs(column: string, have: Row[string] | undefined, want: WriteRow[string]): boolean {
if (JSON_COLUMNS.has(column)) return !deepEqual(parseJsonColumn(have), parseJsonColumn(want));
if (typeof want === 'number') return Number(have) !== want || have === null || have === undefined;
return (have ?? null) !== (want ?? null);
}
export function planCalendars(
selected: SelectedCalendar[],
local: LocalCalendar[],
ctx: CalendarsContext,
): { groups: OpGroup[]; deleteCalendarRows: number[] } {
const groups: OpGroup[] = [];
const rows = new Map<string, LocalCalendar>();
for (const row of local) if (row.syncId && !rows.has(row.syncId)) rows.set(row.syncId, row);
const wanted = new Set<string>();
for (const s of selected) {
// The birthday calendar is the app's own, generated from contacts.
if (s.calendar.id === BIRTHDAY_CALENDAR_ID) continue;
const key = collectionKey(s.jmapAccountId, s.calendar.id);
wanted.add(key);
const values = calendarValues(s, ctx);
const row = rows.get(key);
if (!row) {
// DIRTY is NULL after a sync-adapter insert unless written.
groups.push({ ref: key, ops: [{ op: 'insert', table: 'calendars', values: { ...values, [Calendars.VISIBLE]: 1, [Calendars.DIRTY]: 0 } }] });
continue;
}
const changes: WriteRow = {};
for (const [column, want] of Object.entries(values)) {
if (differs(column, row.cells[column], want)) changes[column] = want;
}
if (Object.keys(changes).length) {
groups.push({ ref: key, ops: [{ op: 'update', table: 'calendars', id: row.calendarRowId, values: changes, expectCount: 1 }] });
}
}
const deleteCalendarRows = local
.filter((row) => row.syncId && parseCollectionKey(row.syncId) && !wanted.has(row.syncId))
.map((row) => row.calendarRowId);
return { groups, deleteCalendarRows };
}
+263
View File
@@ -0,0 +1,263 @@
/**
* What the calendar planner reads and writes on each CalendarContract row,
* how a cell compares to another (an app's `''` is the provider's NULL, a
* `P3600S` is a `PT1H`) and the baseline format in SYNC_DATA4
* (docs/device-sync.md, "Calendar columns" and "Baselines").
*/
import { Attendees, Calendars, Events, Reminders } from '../android-columns';
import type { Cell, Row } from '../types';
import type { EventBaseline } from '../planner';
import { parseJsonColumn, utf8 } from '../common/json';
import { sha256Hex } from '../../lib/sha256';
import { canonicalRRule } from './rrule';
import { exdateSet } from './exdate';
import { durationTextSeconds } from './duration';
/** Calendars.CAN_PARTIALLY_UPDATE, re-exported for the calendar modules. */
export const CAN_PARTIALLY_UPDATE = Calendars.CAN_PARTIALLY_UPDATE;
export const CALENDAR_COLUMNS = [
Calendars._ID,
Calendars._SYNC_ID,
Calendars.NAME,
Calendars.CALENDAR_DISPLAY_NAME,
Calendars.CALENDAR_COLOR,
Calendars.CALENDAR_ACCESS_LEVEL,
Calendars.OWNER_ACCOUNT,
Calendars.VISIBLE,
Calendars.SYNC_EVENTS,
Calendars.CALENDAR_TIME_ZONE,
Calendars.ALLOWED_REMINDERS,
Calendars.ALLOWED_AVAILABILITY,
Calendars.ALLOWED_ATTENDEE_TYPES,
Calendars.MAX_REMINDERS,
Calendars.CAN_ORGANIZER_RESPOND,
CAN_PARTIALLY_UPDATE,
Calendars.CAL_SYNC2,
Calendars.CAL_SYNC3,
] as const;
/** Event columns that map to JSCalendar (a master's projection). */
export const MASTER_CELLS = [
Events.CALENDAR_ID,
Events.TITLE,
Events.DESCRIPTION,
Events.EVENT_LOCATION,
Events.STATUS,
Events.AVAILABILITY,
Events.ACCESS_LEVEL,
Events.EVENT_COLOR,
Events.ORGANIZER,
Events.DTSTART,
Events.DTEND,
Events.DURATION,
Events.EVENT_TIMEZONE,
Events.ALL_DAY,
Events.RRULE,
Events.EXDATE,
] as const;
/** An exception row's projection: no rule of its own, plus the instance it replaces. */
export const EXCEPTION_CELLS = [
Events.CALENDAR_ID,
Events.TITLE,
Events.DESCRIPTION,
Events.EVENT_LOCATION,
Events.STATUS,
Events.AVAILABILITY,
Events.ACCESS_LEVEL,
Events.EVENT_COLOR,
Events.ORGANIZER,
Events.DTSTART,
Events.DTEND,
Events.DURATION,
Events.EVENT_TIMEZONE,
Events.ALL_DAY,
Events.ORIGINAL_INSTANCE_TIME,
Events.ORIGINAL_ALL_DAY,
] as const;
/** Read but never part of a projection: identity, sync state and provider-computed columns. */
const EVENT_EXTRA_COLUMNS = [
Events._ID,
Events._SYNC_ID,
Events.DIRTY,
Events.DELETED,
Events.MUTATORS,
Events.SYNC_DATA1,
Events.SYNC_DATA2,
Events.SYNC_DATA3,
Events.SYNC_DATA4,
Events.SYNC_DATA5,
Events.UID_2445,
Events.HAS_ATTENDEE_DATA,
Events.RDATE,
Events.EXRULE,
Events.LAST_DATE,
Events.ORIGINAL_ID,
Events.ORIGINAL_SYNC_ID,
] as const;
export const EVENT_COLUMNS = [...new Set<string>([...EVENT_EXTRA_COLUMNS, ...MASTER_CELLS, ...EXCEPTION_CELLS])];
export const ATTENDEE_CELLS = [
Attendees.ATTENDEE_EMAIL,
Attendees.ATTENDEE_NAME,
Attendees.ATTENDEE_RELATIONSHIP,
Attendees.ATTENDEE_TYPE,
Attendees.ATTENDEE_STATUS,
] as const;
export const ATTENDEE_COLUMNS = [Attendees._ID, Attendees.EVENT_ID, ...ATTENDEE_CELLS];
export const REMINDER_CELLS = [Reminders.MINUTES, Reminders.METHOD] as const;
export const REMINDER_COLUMNS = [Reminders._ID, Reminders.EVENT_ID, ...REMINDER_CELLS];
// ─── Cells ──────────────────────────────────────────────
const TEXT_COLUMNS = new Set<string>([
Events.TITLE,
Events.DESCRIPTION,
Events.EVENT_LOCATION,
Events.ORGANIZER,
Events.EVENT_TIMEZONE,
Attendees.ATTENDEE_EMAIL,
Attendees.ATTENDEE_NAME,
]);
/** A cell as the bridge may hand it back: numbers stay numbers, `''` and NULL are one value for text. */
export function normalCell(column: string, value: unknown): Cell {
if (value === undefined || value === null) return TEXT_COLUMNS.has(column) ? '' : null;
if (TEXT_COLUMNS.has(column)) return String(value);
if (typeof value === 'number') return value;
if (typeof value === 'string') {
if (value === '') return null;
const n = Number(value);
return Number.isFinite(n) && /^-?\d+(\.\d+)?$/.test(value.trim()) ? n : value;
}
return null;
}
/** Whether two values of a column mean the same, the way device sync reads them. */
export function sameCell(column: string, a: unknown, b: unknown): boolean {
if (column === Events.DURATION) {
const sa = durationTextSeconds(a);
const sb = durationTextSeconds(b);
if (sa !== null || sb !== null) return sa === sb;
return empty(a) === empty(b);
}
if (column === Events.RRULE) return canonicalRRule(a) === canonicalRRule(b);
if (column === Events.EXDATE) return sameSet(exdateSet(a), exdateSet(b));
if (column === Events.ALL_DAY || column === Events.ORIGINAL_ALL_DAY) return truthy(a) === truthy(b);
if (isHashed(a) || isHashed(b)) return textDigest(a) === textDigest(b);
return normalCell(column, a) === normalCell(column, b);
}
const empty = (v: unknown) => v === null || v === undefined || v === '';
const truthy = (v: unknown) => Number(v ?? 0) === 1;
function sameSet(a: Set<string>, b: Set<string>): boolean {
return a.size === b.size && [...a].every((x) => b.has(x));
}
// ─── Long text in baselines ─────────────────────────────
const HASH_PREFIX = 'sha256:';
const TRUNCATED_PREFIX = 'truncated:';
const LONG_TEXT_BYTES = 1024;
/** The largest description written to a row; longer ones are cut, marked in the baseline and never uploaded. */
export const DESCRIPTION_LIMIT_BYTES = 65_536;
function isHashed(v: unknown): boolean {
return typeof v === 'string' && (v.startsWith(HASH_PREFIX) || v.startsWith(TRUNCATED_PREFIX + HASH_PREFIX));
}
/** Text over 1 KB as `sha256:<hex>`, anything else unchanged. */
export function baselineText(value: string): string {
return utf8(value).length > LONG_TEXT_BYTES ? HASH_PREFIX + sha256Hex(utf8(value)) : value;
}
/** A truncated description's baseline: its hash, marked so a later edit is never uploaded. */
export function truncatedBaseline(value: string): string {
return TRUNCATED_PREFIX + HASH_PREFIX + sha256Hex(utf8(value));
}
export function isTruncatedBaseline(value: unknown): boolean {
return typeof value === 'string' && value.startsWith(TRUNCATED_PREFIX);
}
/** What a text cell or its baseline form is compared by: the hash for hashed or long text. */
function textDigest(v: unknown): string {
if (typeof v !== 'string') return empty(v) ? '' : String(v);
if (v.startsWith(TRUNCATED_PREFIX)) return v.slice(TRUNCATED_PREFIX.length);
if (v.startsWith(HASH_PREFIX)) return v;
return utf8(v).length > LONG_TEXT_BYTES ? HASH_PREFIX + sha256Hex(utf8(v)) : v;
}
/** Cuts text to at most `limit` UTF-8 bytes without splitting a character. */
export function truncateUtf8(text: string, limit = DESCRIPTION_LIMIT_BYTES): { text: string; truncated: boolean } {
if (utf8(text).length <= limit) return { text, truncated: false };
let lo = 0;
let hi = text.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (utf8(text.slice(0, mid)).length <= limit) lo = mid;
else hi = mid - 1;
}
let end = lo;
// Never end on the first half of a surrogate pair.
if (end > 0 && /[\uD800-\uDBFF]/.test(text[end - 1])) end -= 1;
return { text: text.slice(0, end), truncated: true };
}
// ─── Baselines (SYNC_DATA4) ─────────────────────────────
/**
* The columns of a row, each in its normal form (numbers as numbers even when
* the provider handed them back as text), so a read-back never differs from
* a baseline by type alone.
*/
function normalRow(row: Row, columns: readonly string[]): Row {
const out: Row = {};
for (const c of columns) {
const v = row[c] ?? null;
out[c] = v === null || TEXT_COLUMNS.has(c) || typeof v !== 'string' ? v : normalCell(c, v);
}
return out;
}
/** A baseline from cells as stored: normal forms, long text hashed, a truncated description marked. */
export function makeBaseline(
cells: Row,
columns: readonly string[],
attendees: Row[],
reminders: Row[],
truncatedDescription = false,
): EventBaseline {
const out = normalRow(cells, columns);
for (const c of columns) {
const v = out[c];
if (c === Events.DESCRIPTION && truncatedDescription && typeof v === 'string') out[c] = truncatedBaseline(v);
else if (typeof v === 'string' && !isHashed(v)) out[c] = baselineText(v);
}
return {
cells: out,
attendees: attendees.map((a) => normalRow(a, ATTENDEE_CELLS)),
reminders: reminders.map((r) => normalRow(r, REMINDER_CELLS)),
};
}
export function encodeBaseline(baseline: EventBaseline): string {
return JSON.stringify({ v: 1, ...baseline });
}
/** SYNC_DATA4 read defensively: another app may have written anything there. */
export function decodeBaseline(text: unknown): EventBaseline | null {
const parsed = parseJsonColumn<Record<string, unknown>>(text);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const cells = parsed.cells;
if (!cells || typeof cells !== 'object' || Array.isArray(cells)) return null;
const rows = (v: unknown): Row[] =>
Array.isArray(v) ? v.filter((r): r is Row => !!r && typeof r === 'object' && !Array.isArray(r)) : [];
return { cells: cells as Row, attendees: rows(parsed.attendees), reminders: rows(parsed.reminders) };
}
+280
View File
@@ -0,0 +1,280 @@
/**
* 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];
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Provider rows → the planner's view of local events and calendars
* (docs/device-sync.md, "Device data model"). Every sync column is read
* defensively: another app could have written anything there, and a value
* that doesn't parse counts as absent.
*
* Exception rows are grouped under their master by ORIGINAL_ID, else by
* ORIGINAL_SYNC_ID. Two masters sharing a `_SYNC_ID` are a
* CONTENT_EXCEPTION_URI split (the provider clones `_SYNC_ID`, SYNC_DATA*
* and UID_2445 and caps the old rule without DIRTY): the newer row is marked
* `clone`, the older `source`. A new master whose UID_2445 another row
* already carries is marked `clone` too, so its create gets a fresh uid
* instead of adopting the other row's server object.
*/
import { Attendees, Calendars, Events, Reminders } from '../android-columns';
import type { LocalAttendee, LocalCalendar, LocalEvent, LocalEventRow, LocalException, LocalReminder, PendingCreate, PoisonMarker } from '../planner';
import type { Row } from '../types';
import type { CalendarEventWire, CalendarLike } from '../wire';
import { parseJsonColumn } from '../common/json';
import { parseObjectRef, pendingUidOf } from '../common/ids';
import { decodeBaseline } from './columns';
import { instanceTimeToKey } from './exceptions';
import { canonicalZone, parseLocalDateTime } from './zoned-time';
const has = (v: unknown) => v !== null && v !== undefined && v !== '';
const num = (v: unknown) => (has(v) && Number.isFinite(Number(v)) ? Number(v) : null);
const str = (v: unknown) => (typeof v === 'string' && v ? v : typeof v === 'number' ? String(v) : null);
export function decodePending(text: unknown): PendingCreate | null {
const p = parseJsonColumn<Record<string, unknown>>(text);
if (!p || typeof p.uid !== 'string' || !p.uid || typeof p.target !== 'string' || !p.target) return null;
return { uid: p.uid, target: p.target };
}
export function decodePoison(text: unknown): PoisonMarker | null {
const p = parseJsonColumn<Record<string, unknown>>(text);
if (!p || typeof p.fp !== 'string' || typeof p.type !== 'string') return null;
const n = Number(p.n);
const until = Number(p.until);
return {
fp: p.fp,
type: p.type,
...(typeof p.description === 'string' ? { description: p.description } : {}),
n: Number.isFinite(n) ? n : 1,
until: Number.isFinite(until) ? until : 0,
};
}
export function decodeShadow(text: unknown): CalendarEventWire | null {
const e = parseJsonColumn<CalendarEventWire>(text);
return e && typeof e === 'object' && !Array.isArray(e) && typeof e.id === 'string' ? e : null;
}
/** Whether a row is an exception (ORIGINAL_ID or ORIGINAL_SYNC_ID set): it never uploads as an event of its own. */
export function isExceptionRow(cells: Row): boolean {
return has(cells[Events.ORIGINAL_ID]) || has(cells[Events.ORIGINAL_SYNC_ID]);
}
function decodeRow(row: Row, attendees: LocalAttendee[], reminders: LocalReminder[]): LocalEventRow {
const cells: Row = { ...row };
for (const c of [Events.SYNC_DATA1, Events.SYNC_DATA3, Events.SYNC_DATA4, Events.SYNC_DATA5]) delete cells[c];
return {
eventId: Number(row[Events._ID]),
calendarRowId: Number(row[Events.CALENDAR_ID]),
syncId: str(row[Events._SYNC_ID]),
dirty: Number(row[Events.DIRTY] ?? 0) === 1,
deleted: Number(row[Events.DELETED] ?? 0) === 1,
cells,
attendees,
reminders,
baseline: decodeBaseline(row[Events.SYNC_DATA4]),
pending: decodePending(row[Events.SYNC_DATA3]),
poison: decodePoison(row[Events.SYNC_DATA5]),
mutators: str(row[Events.MUTATORS]),
};
}
/** The recurrence id of an exception row: SYNC_DATA2 on our rows, else ORIGINAL_INSTANCE_TIME in the master's zone. */
export function exceptionKey(row: LocalEventRow, master: LocalEventRow | null): string | null {
const stored = row.cells[Events.SYNC_DATA2];
if (typeof stored === 'string' && parseLocalDateTime(stored)) return stored;
const oit = num(row.cells[Events.ORIGINAL_INSTANCE_TIME]);
if (oit === null) return null;
const source = master ?? row;
const allDay = Number(source.cells[Events.ALL_DAY] ?? row.cells[Events.ORIGINAL_ALL_DAY] ?? 0) === 1;
const zone = canonicalZone(source.cells[Events.EVENT_TIMEZONE]) ?? 'UTC';
return instanceTimeToKey(oit, allDay, allDay ? 'UTC' : zone);
}
export function decodeEvents(events: Row[], attendees: Row[], reminders: Row[]): LocalEvent[] {
const attendeesOf = new Map<number, LocalAttendee[]>();
for (const a of attendees) {
const id = Number(a[Attendees.EVENT_ID]);
const list = attendeesOf.get(id) ?? [];
const cells: Row = { ...a };
delete cells[Attendees._ID];
delete cells[Attendees.EVENT_ID];
list.push({ id: Number(a[Attendees._ID]), cells });
attendeesOf.set(id, list);
}
const remindersOf = new Map<number, LocalReminder[]>();
for (const r of reminders) {
const id = Number(r[Reminders.EVENT_ID]);
const list = remindersOf.get(id) ?? [];
const cells: Row = { ...r };
delete cells[Reminders._ID];
delete cells[Reminders.EVENT_ID];
list.push({ id: Number(r[Reminders._ID]), cells });
remindersOf.set(id, list);
}
const masters: LocalEvent[] = [];
const exceptionRows: LocalEventRow[] = [];
for (const row of [...events].sort((a, b) => Number(a[Events._ID]) - Number(b[Events._ID]))) {
const id = Number(row[Events._ID]);
const decoded = decodeRow(row, attendeesOf.get(id) ?? [], remindersOf.get(id) ?? []);
if (isExceptionRow(decoded.cells)) {
exceptionRows.push(decoded);
} else {
masters.push({ ...decoded, shadow: decodeShadow(row[Events.SYNC_DATA1]), exceptions: [] });
}
}
const byId = new Map(masters.map((m) => [m.eventId, m]));
const bySyncId = new Map<string, LocalEvent[]>();
for (const m of masters) {
if (!m.syncId) continue;
const list = bySyncId.get(m.syncId) ?? [];
list.push(m);
bySyncId.set(m.syncId, list);
}
const orphans: LocalEvent[] = [];
for (const row of exceptionRows) {
const originalId = num(row.cells[Events.ORIGINAL_ID]);
let master = originalId !== null ? byId.get(originalId) : undefined;
if (!master) {
const candidates = bySyncId.get(String(row.cells[Events.ORIGINAL_SYNC_ID] ?? '')) ?? [];
master = candidates.find((m) => m.calendarRowId === row.calendarRowId) ?? candidates[0];
}
if (!master) {
orphans.push({ ...row, shadow: null, exceptions: [] });
continue;
}
const exception: LocalException = {
...row,
recurrenceId: exceptionKey(row, master),
originalInstanceTime: num(row.cells[Events.ORIGINAL_INSTANCE_TIME]),
};
master.exceptions.push(exception);
}
// A CONTENT_EXCEPTION_URI split: the oldest row keeps the identity, the others are new events.
for (const group of bySyncId.values()) {
if (group.length < 2) continue;
group.sort((a, b) => a.eventId - b.eventId);
group[0].split = 'source';
for (const clone of group.slice(1)) clone.split = 'clone';
}
// A new row carrying a uid another row uses: create it under a fresh uid.
const uidCount = new Map<string, number>();
const uidsOf = (m: LocalEvent) => {
const uids = new Set<string>();
const uid = str(m.cells[Events.UID_2445]);
if (uid) uids.add(uid);
const pendingUid = pendingUidOf(m.syncId) ?? m.pending?.uid;
if (pendingUid) uids.add(pendingUid);
return uids;
};
for (const m of masters) for (const uid of uidsOf(m)) uidCount.set(uid, (uidCount.get(uid) ?? 0) + 1);
for (const m of masters) {
if (m.split || m.syncId !== null) continue;
if ([...uidsOf(m)].some((uid) => (uidCount.get(uid) ?? 0) > 1)) m.split = 'clone';
}
return [...masters, ...orphans];
}
export function decodeCalendar(row: Row): LocalCalendar {
const flags = parseJsonColumn<Record<string, unknown>>(row[Calendars.CAL_SYNC3]);
const shadow = parseJsonColumn<CalendarLike>(row[Calendars.CAL_SYNC2]);
const readOnly = flags?.readOnly === 'rights' || flags?.readOnly === 'subscription' ? flags.readOnly : undefined;
return {
calendarRowId: Number(row[Calendars._ID]),
syncId: str(row[Calendars._SYNC_ID]),
cells: { ...row },
shadow: shadow && typeof shadow === 'object' && !Array.isArray(shadow) && typeof shadow.id === 'string' ? shadow : null,
flags: flags && typeof flags === 'object' ? { ...(readOnly ? { readOnly } : {}), ...(flags.taskOnly === true ? { taskOnly: true } : {}) } : null,
};
}
/** Whether a master has no server identity yet (no `_SYNC_ID`, or a pending one). */
export function isNewMaster(local: LocalEventRow): boolean {
return parseObjectRef(local.syncId) === null;
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Durations in the three dialects device sync meets (docs/device-sync.md,
* "Timing"):
*
* - JSCalendar (RFC 8984 §1.4.6) on the server: weeks, days and a time part
* with fractional seconds; `P1W` arrives from CalDAV DTENDs. The app's own
* parsers ignore weeks (`P1W` → 0) and signs, so none of them is reused.
* - AOSP's calendarcommon2 Duration in the DURATION column: `[+-]P` then
* `<digits><W|D|H|M|S>`, `T` ignored, `M` always minutes, no fractions and
* no `Y`. All-day rows must say `P<n>D`: CalendarProvider rewrites `P<n>S`
* with `Integer.parseInt` and throws on anything else ending in `S`.
* - Whole days are nominal (a calendar day in the event's zone), the rest is
* exact time.
*/
import {
addToWall,
canonicalZone,
resolveWallClock,
wallClockAt,
wallMs,
type WallClock,
} from './zoned-time';
export interface Duration {
negative: boolean;
/** Whole nominal days (weeks included). */
days: number;
/** The exact part in seconds (may be fractional). */
seconds: number;
}
const DAY_S = 86_400;
/**
* Parses any duration either side writes: RFC 8984/5545 text, signed or not,
* weeks and days, `M` as minutes, a fraction on the seconds, lower case, and
* AOSP's forms (`P3600S`, `P1DT0S`, `P0DT1H30M0S`). Null for anything else
* (years, months before the time part, an empty `P`).
*/
export function parseDuration(text: unknown): Duration | null {
if (typeof text !== 'string') return null;
const m = /^([+-])?P(.*)$/.exec(text.trim().toUpperCase());
if (!m || !m[2]) return null;
let days = 0;
let seconds = 0;
let any = false;
const token = /^(?:T|(\d+(?:[.,]\d+)?)([WDHMS]))/;
let rest = m[2];
while (rest) {
const t = token.exec(rest);
if (!t) return null;
rest = rest.slice(t[0].length);
if (t[0] === 'T') continue;
const unit = t[2];
const fractional = /[.,]/.test(t[1]);
if (fractional && unit !== 'S') return null;
const n = Number(t[1].replace(',', '.'));
any = true;
if (unit === 'W') days += n * 7;
else if (unit === 'D') days += n;
else if (unit === 'H') seconds += n * 3600;
else if (unit === 'M') seconds += n * 60;
else seconds += n;
}
if (!any) return null;
return { negative: m[1] === '-', days, seconds };
}
/** Total seconds with a day counted as 24 hours, signed: CalendarProvider's arithmetic. */
export function durationSeconds(d: Duration): number {
const total = d.days * DAY_S + d.seconds;
return d.negative ? -total : total;
}
/** Seconds of a duration text, or null when it can't be read. */
export function durationTextSeconds(text: unknown): number | null {
const d = parseDuration(text);
return d ? durationSeconds(d) : null;
}
/**
* A JSCalendar Duration in the strict grammar: `P<n>D`, `T<h>H<m>M<s>S` with
* minutes kept between hours and seconds (`PT1H0M5S`; `PT1H5S` is not
* valid), whole seconds, never weeks, `PT0S` for zero. Negative input is
* treated as zero: an event's duration is unsigned.
*/
export function formatJsDuration(days: number, seconds: number): string {
const d = Math.max(0, Math.floor(days));
let s = Math.max(0, Math.round(seconds));
let out = 'P';
if (d > 0) out += `${d}D`;
if (s > 0) {
const h = Math.floor(s / 3600);
s -= h * 3600;
const min = Math.floor(s / 60);
s -= min * 60;
out += 'T';
if (h > 0) out += `${h}H`;
if (min > 0 || (h > 0 && s > 0)) out += `${min}M`;
if (s > 0) out += `${s}S`;
}
return out === 'P' ? 'PT0S' : out;
}
/** A fixed number of seconds as a JSCalendar duration, with whole days split off as nominal days. */
export function formatJsDurationSeconds(totalSeconds: number): string {
const s = Math.max(0, Math.round(totalSeconds));
return formatJsDuration(Math.floor(s / DAY_S), s % DAY_S);
}
/**
* The DURATION column: `P<n>D` for all-day rows (at least one day; a time
* part is rounded up), `P<seconds>S` for timed rows. Never a fraction, a
* `Y`, a sign or weeks.
*/
export function formatAospDuration(totalSeconds: number, allDay: boolean): string {
if (allDay) return `P${Math.max(1, Math.ceil(totalSeconds / DAY_S))}D`;
return `P${Math.max(0, Math.round(totalSeconds))}S`;
}
/** Whole days of an all-day duration on the device: at least one, a time part rounded up. */
export function allDayDays(d: Duration | null): number {
if (!d || d.negative) return 1;
return Math.max(1, Math.ceil((d.days * DAY_S + d.seconds) / DAY_S));
}
/**
* The end of a duration that starts at `startUtc`: whole days in the zone's
* wall clock, then exact seconds. With no whole days the start instant is
* used as is, so a start that is the second instant of a repeated hour does
* not collapse onto the first one.
*/
export function addDuration(startUtc: number, d: Duration, zone: string): number {
const days = d.negative ? 0 : d.days;
const seconds = d.negative ? 0 : d.seconds;
if (days === 0) return startUtc + Math.round(seconds * 1000);
const z = canonicalZone(zone) ?? 'UTC';
const wall = wallClockAt(startUtc, z);
const shifted = resolveWallClock(addToWall(wall, days), z).utc + (startUtc % 1000 + 1000) % 1000;
return shifted + Math.round(seconds * 1000);
}
/**
* DTSTART/DTEND as a duration with whole days nominal in `zone` and the rest
* exact: 00:00 to 00:00 the next day is `P1D` even across a DST change.
*/
export function durationBetween(startUtc: number, endUtc: number, zone: string): Duration {
if (!(endUtc > startUtc)) return { negative: false, days: 0, seconds: 0 };
const z = canonicalZone(zone) ?? 'UTC';
const sw = wallClockAt(startUtc, z);
const ew = wallClockAt(endUtc, z);
let days = Math.round((dateMs(ew) - dateMs(sw)) / 86_400_000);
if (timeOfDay(ew) < timeOfDay(sw)) days -= 1;
days = Math.max(0, days);
let anchor = days > 0 ? addDuration(startUtc, { negative: false, days, seconds: 0 }, z) : startUtc;
while (days > 0 && anchor > endUtc) {
days -= 1;
anchor = days > 0 ? addDuration(startUtc, { negative: false, days, seconds: 0 }, z) : startUtc;
}
return { negative: false, days, seconds: Math.max(0, (endUtc - anchor) / 1000) };
}
function dateMs(w: WallClock): number {
return wallMs({ ...w, hour: 0, minute: 0, second: 0 });
}
function timeOfDay(w: WallClock): number {
return w.hour * 3600 + w.minute * 60 + w.second;
}
/** Equal lengths with a day counted as 24 hours (`PT24H` and `P1D` are the same to CalendarProvider). */
export function sameDurationLength(a: unknown, b: unknown): boolean {
const sa = durationTextSeconds(a);
const sb = durationTextSeconds(b);
if (sa === null || sb === null) return (a ?? null) === (b ?? null);
return sa === sb;
}
+248
View File
@@ -0,0 +1,248 @@
/**
* Device edits of attendees, reminders and the location, turned into
* JSCalendar values (docs/device-sync.md, "Other properties").
*
* - Attendees: the user's own status is their RSVP; an added row is a new
* participant (`roles: {attendee: true}`, `needs-action`, `expectReply`);
* a removed row deletes its participants. Other people's statuses, the
* organizer relationship and, for lossy editors, the attendee type are not
* the device's to change.
* - Reminders replace the alerts a Reminders row can hold, keeping the keys
* of those still there; the others stay.
* - The location edits the first location's name, adds one, or deletes it.
*
* Changes come back as paths (raw member names) so the caller escapes them
* once, with `ptr`, wherever the patch is rooted.
*/
import type { Alert, Participant } from '../../api/types';
import { Attendees, AttendeeType, Events } from '../android-columns';
import type { Row } from '../types';
import type { CalendarLike, CalendarEventWire } from '../wire';
import { clone } from '../common/json';
import { addressGroups, isSelfAddress, participantIdFor, type SelfContext } from './attendees';
import { attendeeDiffers, attendeesByKey } from './units';
import { effectiveAlerts, remindersToAlerts } from './reminders';
import { participationFromDevice } from './values';
export type MintKey = (taken?: Iterable<string>) => string;
/** One change: a path of raw member names below some root, and the value (null removes). */
export interface PathChange {
path: string[];
value: unknown;
}
export interface AttendeeEdit {
kind: 'add' | 'remove' | 'change';
key: string;
row?: Row;
baseline?: Row;
}
/** What the device changed in the attendee rows since `baselineRows`. */
export function attendeeEdits(currentRows: Row[], baselineRows: Row[], self: SelfContext, lossy: boolean): AttendeeEdit[] {
const cur = attendeesByKey(currentRows, self);
const bl = attendeesByKey(baselineRows, self);
const edits: AttendeeEdit[] = [];
for (const key of new Set([...bl.keys(), ...cur.keys()])) {
const row = cur.get(key);
const baseline = bl.get(key);
if (row && !baseline) edits.push({ kind: 'add', key, row });
else if (!row && baseline) edits.push({ kind: 'remove', key, baseline });
else if (attendeeDiffers(row, baseline, lossy)) edits.push({ kind: 'change', key, row, baseline });
}
return edits;
}
/** Whether an attendee address key is the user's. */
export function isSelfKey(key: string): boolean {
return key.startsWith('self:');
}
/** A participant for an attendee row an app added. */
export function newParticipant(row: Row, self: SelfContext): Participant {
const email = String(row[Attendees.ATTENDEE_EMAIL] ?? '').trim();
const p: Participant = {
'@type': 'Participant',
calendarAddress: `mailto:${email}`,
roles: { attendee: true },
participationStatus: 'needs-action',
expectReply: true,
};
const name = row[Attendees.ATTENDEE_NAME];
if (typeof name === 'string' && name.trim()) p.name = name.trim();
const type = Number(row[Attendees.ATTENDEE_TYPE]);
if (type === AttendeeType.OPTIONAL) p.roles = { attendee: true, optional: true };
if (type === AttendeeType.RESOURCE) p.kind = 'resource';
if (isSelfAddress(email, self)) {
const status = participationFromDevice(row[Attendees.ATTENDEE_STATUS]);
if (status) p.participationStatus = status;
delete p.expectReply;
}
return p;
}
export interface AttendeeChanges {
/** Changes below the participants map (`[id]`, `[id, 'participationStatus']`, …). */
changes: PathChange[];
/** The whole map after the edits. */
participants: Record<string, Participant>;
/** The user's own status changed. */
rsvp: boolean;
/** Someone was added or removed, or a name or type changed. */
others: boolean;
}
/** Applies attendee edits to a participants map (not changed in place). */
export function applyAttendeeEdits(
participants: Record<string, Participant> | null | undefined,
edits: AttendeeEdit[],
self: SelfContext,
mintKey: MintKey,
lossy: boolean,
): AttendeeChanges {
const map = clone(participants ?? {}) as Record<string, Participant>;
const changes: PathChange[] = [];
let rsvp = false;
let others = false;
const groups = addressGroups(map, self);
const idsOf = (key: string) => groups.find((g) => g.key === key)?.ids ?? [];
const setStatus = (id: string, row: Row, baseline?: Row) => {
if (baseline && Number(row[Attendees.ATTENDEE_STATUS]) === Number(baseline[Attendees.ATTENDEE_STATUS])) return;
const status = participationFromDevice(row[Attendees.ATTENDEE_STATUS]);
if (!status || map[id].participationStatus === status) return;
map[id] = { ...map[id], participationStatus: status };
changes.push({ path: [id, 'participationStatus'], value: status });
rsvp = true;
};
for (const edit of edits) {
const selfRow = isSelfKey(edit.key);
if (edit.kind === 'add') {
const existing = participantIdFor(map, edit.row![Attendees.ATTENDEE_EMAIL], self);
if (existing) {
// The server has this address already (added meanwhile): only the user's own status counts.
if (selfRow) setStatus(existing, edit.row!);
continue;
}
// An app adding the owner's row next to new guests: Stalwart adds the organizer itself.
if (selfRow) continue;
const id = mintKey(Object.keys(map));
map[id] = newParticipant(edit.row!, self);
changes.push({ path: [id], value: map[id] });
others = true;
} else if (edit.kind === 'remove') {
if (selfRow) continue;
for (const id of idsOf(edit.key)) {
delete map[id];
changes.push({ path: [id], value: null });
others = true;
}
} else {
const id = participantIdFor(map, edit.row![Attendees.ATTENDEE_EMAIL], self);
if (!id) continue;
if (selfRow) setStatus(id, edit.row!, edit.baseline);
const name = edit.row![Attendees.ATTENDEE_NAME];
if (String(name ?? '') !== String(edit.baseline![Attendees.ATTENDEE_NAME] ?? '')) {
if (typeof name === 'string' && name.trim()) {
map[id] = { ...map[id], name: name.trim() };
changes.push({ path: [id, 'name'], value: name.trim() });
} else if (map[id].name !== undefined) {
const { name: _gone, ...rest } = map[id];
map[id] = rest;
changes.push({ path: [id, 'name'], value: null });
}
others = true;
}
const type = Number(edit.row![Attendees.ATTENDEE_TYPE]);
if (!lossy && type !== Number(edit.baseline![Attendees.ATTENDEE_TYPE])) {
const roles = { ...(map[id].roles ?? { attendee: true }) };
if (type === AttendeeType.OPTIONAL) roles.optional = true;
else delete roles.optional;
map[id] = { ...map[id], roles };
changes.push({ path: [id, 'roles'], value: roles });
if (type === AttendeeType.RESOURCE && map[id].kind !== 'resource') {
map[id] = { ...map[id], kind: 'resource' };
changes.push({ path: [id, 'kind'], value: 'resource' });
} else if (type !== AttendeeType.RESOURCE && map[id].kind === 'resource') {
const { kind: _gone, ...rest } = map[id];
map[id] = rest;
changes.push({ path: [id, 'kind'], value: null });
}
others = true;
}
}
}
return { changes, participants: map, rsvp, others };
}
export interface AlertChanges {
/** Changes relative to the event: `['alerts', k]`, `['useDefaultAlerts']` or the whole `['alerts']`. */
changes: PathChange[];
/** The alerts in force afterwards. */
alerts: Record<string, Alert>;
}
/**
* The device's reminders as alert changes. With `useDefaultAlerts` the
* calendar's defaults were in force: the event switches to explicit alerts
* (built from them). An event without alerts gets the whole map, since a
* pointer below a missing property fails.
*/
export function reminderChanges(
event: Pick<CalendarEventWire, 'alerts' | 'useDefaultAlerts' | 'showWithoutTime'>,
calendar: CalendarLike | undefined,
reminders: Row[],
mintKey: MintKey,
): AlertChanges {
const current = effectiveAlerts(event, calendar);
const { alerts, removed, added } = remindersToAlerts(current, reminders, mintKey);
const changes: PathChange[] = [];
if (event.useDefaultAlerts) {
changes.push({ path: ['useDefaultAlerts'], value: false });
changes.push({ path: ['alerts'], value: Object.keys(alerts).length ? alerts : null });
} else if (!Object.keys(event.alerts ?? {}).length) {
if (Object.keys(added).length) changes.push({ path: ['alerts'], value: alerts });
} else {
for (const key of removed) changes.push({ path: ['alerts', key], value: null });
for (const [key, alert] of Object.entries(added)) changes.push({ path: ['alerts', key], value: alert });
}
return { changes, alerts };
}
/** The location edit, as changes relative to the event and as the resulting map. */
export function locationChanges(
event: Pick<CalendarEventWire, 'locations'>,
name: unknown,
mintKey: MintKey,
): { changes: PathChange[]; locations: Record<string, unknown> | null } {
const locations = clone((event.locations ?? {}) as Record<string, Record<string, unknown>>);
const first = Object.keys(locations)[0];
const text = typeof name === 'string' ? name.trim() : '';
const changes: PathChange[] = [];
if (!text) {
if (first) {
delete locations[first];
changes.push({ path: ['locations', first], value: null });
}
} else if (first) {
locations[first] = { ...locations[first], name: text };
changes.push({ path: ['locations', first, 'name'], value: text });
} else {
const key = mintKey();
locations[key] = { '@type': 'Location', name: text };
changes.push({ path: ['locations'], value: { [key]: locations[key] } });
}
return { changes, locations: Object.keys(locations).length ? locations : null };
}
/** The title cell as JSCalendar text. */
export function titleValue(cells: Row): string {
const t = cells[Events.TITLE];
return typeof t === 'string' ? t : t === null || t === undefined ? '' : String(t);
}
/** The description cell as JSCalendar text; null removes it. */
export function descriptionValue(cells: Row): string | null {
const d = cells[Events.DESCRIPTION];
return typeof d === 'string' && d ? d : null;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* 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>) : {};
}
+114
View File
@@ -0,0 +1,114 @@
/**
* The EXDATE column ↔ excluded recurrence overrides (docs/device-sync.md,
* "Recurrence"). AOSP's RecurrenceSet reads lines split by `\n`, each
* `[<tzid>;]<date>[,<date>…]` with a bare Olson id as prefix; dates are
* `YYYYMMDD`, `YYYYMMDDTHHMMSS` or `…Z`, and a line without a prefix is UTC,
* not the event's zone. Device sync writes one line without a prefix: UTC
* date-times for timed events, dates for all-day ones.
*/
import {
canonicalZone,
dateToUtcMidnight,
formatDate,
localToUtc,
resolveWallClock,
utcMidnightToDate,
utcToLocal,
wallFromMs,
type WallClock,
} from './zoned-time';
import type { RuleZone } from './rrule';
const compactUtc = (utc: number) => {
const w = wallFromMs(utc);
const p = (n: number) => String(n).padStart(2, '0');
return `${formatDate(w).replace(/-/g, '')}T${p(w.hour)}${p(w.minute)}${p(w.second)}Z`;
};
/** One EXDATE entry for a recurrence id (a LocalDateTime in the event's zone). */
export function exdateEntry(key: string, ctx: RuleZone): string | null {
if (ctx.allDay) {
const midnight = dateToUtcMidnight(key);
return midnight === null ? null : formatDate(wallFromMs(midnight)).replace(/-/g, '');
}
const utc = localToUtc(key, ctx.zone);
return utc === null ? null : compactUtc(utc);
}
/** The EXDATE column for a set of recurrence ids, sorted; null for none. */
export function formatExdate(keys: Iterable<string>, ctx: RuleZone): string | null {
const entries = [...new Set([...keys].map((k) => exdateEntry(k, ctx)).filter((e): e is string => !!e))].sort();
return entries.length ? entries.join(',') : null;
}
interface ParsedEntry {
/** The instant (timed) or UTC midnight of the date. */
utc: number;
dateOnly: boolean;
}
function parseEntry(text: string, zone: string | null): ParsedEntry | null {
const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/i.exec(text.trim());
if (!m) return null;
const wall: WallClock = {
year: Number(m[1]),
month: Number(m[2]),
day: Number(m[3]),
hour: Number(m[4] ?? 0),
minute: Number(m[5] ?? 0),
second: Number(m[6] ?? 0),
};
const dateOnly = m[4] === undefined;
if (m[7] || !zone) return { utc: resolveWallClock(wall, 'UTC').utc, dateOnly };
return { utc: resolveWallClock(wall, zone).utc, dateOnly };
}
/** Every entry of an EXDATE (or RDATE) column as an instant; garbage is skipped. */
export function exdateInstants(text: unknown): ParsedEntry[] {
if (typeof text !== 'string' || !text.trim()) return [];
const out: ParsedEntry[] = [];
for (const line of text.split(/\r?\n/)) {
if (!line.trim()) continue;
const semi = line.indexOf(';');
const zone = semi >= 0 ? canonicalZone(line.slice(0, semi).trim()) : null;
const dates = semi >= 0 ? line.slice(semi + 1) : line;
for (const d of dates.split(',')) {
const entry = parseEntry(d, zone);
if (entry) out.push(entry);
}
}
return out;
}
/**
* The recurrence ids an EXDATE column excludes, in the event's zone
* (all-day: the date at midnight). A timed entry's key is its instant in the
* zone; a date-only entry of a timed event is AOSP's UTC midnight of it.
*/
export function parseExdate(text: unknown, ctx: RuleZone): string[] {
const keys = new Set<string>();
for (const entry of exdateInstants(text)) {
keys.add(ctx.allDay ? utcMidnightToDate(entry.utc) : utcToLocal(entry.utc, ctx.zone));
}
return [...keys].sort();
}
/**
* The entries as a set of normalised instants (`YYYYMMDDTHHMMSSZ`, or
* `YYYYMMDD` for dates and for every entry of an all-day event): the
* column's meaning independent of how an app spelled it.
*/
export function exdateSet(text: unknown, allDay = false): Set<string> {
const out = new Set<string>();
for (const entry of exdateInstants(text)) {
out.add(entry.dateOnly || allDay ? formatDate(wallFromMs(entry.utc)).replace(/-/g, '') : compactUtc(entry.utc));
}
return out;
}
/** The recurrence id of one normalised entry (see `exdateSet`). */
export function exdateEntryKey(entry: string, ctx: RuleZone): string | null {
const parsed = parseEntry(entry, null);
if (!parsed) return null;
return ctx.allDay ? utcMidnightToDate(parsed.utc) : utcToLocal(parsed.utc, ctx.zone);
}
+195
View File
@@ -0,0 +1,195 @@
/**
* A server event as the rows it becomes on the device: the master row's
* mapped cells, its attendees and reminders, and one exception row per
* override that is not an exclusion (docs/device-sync.md, "Calendar
* mapping"). The cells are what CalendarProvider stores after the write:
* all-day times at UTC midnight, DURATION only on recurring masters, the
* organizer the provider would fill in, STATUS never NULL. Predicting the
* provider keeps echo downloads free of writes.
*/
import { Events } from '../android-columns';
import type { CalendarContext } from '../planner';
import type { Row } from '../types';
import type { CalendarEventWire } from '../wire';
import { htmlToPlainText } from '../../lib/compose-html';
import { attendeeRows, hasMailtoParticipants, organizerAddress } from './attendees';
import { truncateUtf8 } from './columns';
import { formatExdate } from './exdate';
import { instanceOf, isExcluded, keyToInstanceTime, overridesOf } from './exceptions';
import { effectiveAlerts, reminderRows } from './reminders';
import { isRuleRepresentable, ruleToRRule } from './rrule';
import { eventZone, timingCells } from './timing';
import {
availabilityToDevice,
cssColorToArgb,
privacyToDevice,
statusToDevice,
} from './values';
import { parseLocalDateTime } from './zoned-time';
export type ImageContext = Pick<
CalendarContext,
'deviceZone' | 'ownerAccount' | 'selfAddresses' | 'reminderOwner' | 'calendar'
>;
export interface RowImage {
cells: Row;
attendees: Row[];
/** Null when Bulwark owns reminders: Reminders rows are then left alone. */
reminders: Row[] | null;
/** Whether the event has `mailto:` participants (ORGANIZER is written only then). */
hasParticipants: boolean;
truncatedDescription: boolean;
}
export interface ExceptionImage extends RowImage {
key: string;
}
export interface EventImage {
master: RowImage;
/** Exception rows by recurrence id. */
exceptions: Map<string, ExceptionImage>;
/** Recurrence ids of `{excluded: true}` overrides (the EXDATE column). */
excluded: Set<string>;
zone: string;
allDay: boolean;
floating: boolean;
recurring: boolean;
/** False when the rule needs RSCALE, SKIP or leap months: shown approximately, timing never uploaded. */
representable: boolean;
}
/** DESCRIPTION: HTML turned to text (and uploaded as text/plain only when edited), cut at 64 KB. */
export function descriptionCell(event: CalendarEventWire): { text: string | null; truncated: boolean } {
let text = typeof event.description === 'string' ? event.description : '';
if (text && String(event.descriptionContentType ?? '').toLowerCase().startsWith('text/html')) text = htmlToPlainText(text);
if (!text) return { text: null, truncated: false };
const cut = truncateUtf8(text);
return { text: cut.text, truncated: cut.truncated };
}
/** The first location's name, in server order. */
export function locationCell(event: CalendarEventWire): string | null {
const first = Object.values(event.locations ?? {})[0];
const name = first && typeof first === 'object' ? first.name : undefined;
return typeof name === 'string' && name ? name : null;
}
function rowImage(
event: CalendarEventWire,
timing: Row,
calendarRowId: number,
calendarId: string,
ctx: ImageContext,
): RowImage {
const description = descriptionCell(event);
const hasParticipants = hasMailtoParticipants(event.participants);
const cells: Row = {
[Events.CALENDAR_ID]: calendarRowId,
[Events.TITLE]: typeof event.title === 'string' && event.title ? event.title : null,
[Events.DESCRIPTION]: description.text,
[Events.EVENT_LOCATION]: locationCell(event),
[Events.STATUS]: statusToDevice(event.status),
[Events.AVAILABILITY]: availabilityToDevice(event.freeBusyStatus),
[Events.ACCESS_LEVEL]: privacyToDevice(event.privacy),
[Events.EVENT_COLOR]: cssColorToArgb(event.color),
[Events.ORGANIZER]: (hasParticipants ? organizerAddress(event, ctx) : null) ?? ctx.ownerAccount,
...timing,
};
const reminders =
ctx.reminderOwner === 'device'
? reminderRows(effectiveAlerts(event, ctx.calendar(calendarId)))
: null;
return {
cells,
attendees: hasParticipants ? attendeeRows(event, ctx) : [],
reminders,
hasParticipants,
truncatedDescription: description.truncated,
};
}
/**
* The rows of a server event in the calendar row `calendarRowId` (server
* calendar `calendarId`, whose default alerts apply). Null when the event's
* start can't be read.
*/
export function eventImage(
event: CalendarEventWire,
calendarRowId: number,
calendarId: string,
ctx: ImageContext,
): EventImage | null {
const { zone, allDay, floating } = eventZone(event, ctx.deviceZone);
const rule = event.recurrenceRule ?? null;
const rrule = rule ? ruleToRRule(rule, { allDay, zone }) : null;
const recurring = rrule !== null;
const timing = timingCells(event, recurring, ctx.deviceZone);
if (!timing) return null;
const overrides = recurring ? overridesOf(event) : {};
const excluded = new Set<string>();
const exceptions = new Map<string, ExceptionImage>();
for (const [key, override] of Object.entries(overrides)) {
if (!parseLocalDateTime(key) || !override || typeof override !== 'object') continue;
if (isExcluded(override)) {
excluded.add(key);
continue;
}
const originalInstanceTime = keyToInstanceTime(key, allDay, zone);
if (originalInstanceTime === null) continue;
const instance = instanceOf(event, key, override);
// An instance keeps the series' kind of time; only its start and length move.
const instanceTiming = timingCells(
{ ...instance, showWithoutTime: event.showWithoutTime, timeZone: instance.timeZone ?? event.timeZone },
false,
ctx.deviceZone,
);
if (!instanceTiming) continue;
const image = rowImage(instance, instanceTiming, calendarRowId, calendarId, ctx);
image.cells[Events.ORIGINAL_INSTANCE_TIME] = originalInstanceTime;
image.cells[Events.ORIGINAL_ALL_DAY] = allDay ? 1 : 0;
exceptions.set(key, { ...image, key });
}
const master = rowImage(event, timing, calendarRowId, calendarId, ctx);
master.cells[Events.RRULE] = rrule;
master.cells[Events.EXDATE] = recurring ? formatExdate(excluded, { allDay, zone }) : null;
return {
master,
exceptions,
excluded,
zone,
allDay,
floating,
recurring,
representable: isRuleRepresentable(rule),
};
}
/**
* The row an instance of the series would have without an exception row:
* the master at that recurrence id. The baseline of an exception an app
* creates, and what its changes are measured against.
*/
export function plainInstanceImage(
master: CalendarEventWire,
key: string,
calendarRowId: number,
calendarId: string,
ctx: ImageContext,
): ExceptionImage | null {
const { zone, allDay } = eventZone(master, ctx.deviceZone);
const override = overridesOf(master)[key];
const instance = instanceOf(master, key, override && !isExcluded(override) ? override : null);
const timing = timingCells(
{ ...instance, showWithoutTime: master.showWithoutTime, timeZone: instance.timeZone ?? master.timeZone },
false,
ctx.deviceZone,
);
const originalInstanceTime = keyToInstanceTime(key, allDay, zone);
if (!timing || originalInstanceTime === null) return null;
const image = rowImage(instance, timing, calendarRowId, calendarId, ctx);
image.cells[Events.ORIGINAL_INSTANCE_TIME] = originalInstanceTime;
image.cells[Events.ORIGINAL_ALL_DAY] = allDay ? 1 : 0;
return { ...image, key };
}
+460
View File
@@ -0,0 +1,460 @@
/**
* A server event into its rows (docs/device-sync.md, "Change detection and
* merge rules"). One writer serves downloads, accepted uploads, reverts and
* zone changes:
*
* - a clean row is rewritten to the image of the server event (only cells
* that differ are written, so an echo writes nothing);
* - a dirty row is merged per unit against its baseline (what the device
* changed) and the old shadow's image (what the server changed): the server
* wins real conflicts, local changes the server did not touch stay dirty
* for the upload;
* - exception rows are matched to overrides by recurrence id and merged the
* same way; a device deletion of an instance wins over a server edit, a
* server removal wins over a device edit.
*
* Every write into an existing item is guarded: the group first asserts
* DIRTY and the projection the plan was made from (with attendees and
* reminders when the item is dirty) and the number of exception rows.
*/
import { Events } from '../android-columns';
import type { CalendarContext, EventBaseline, LocalEvent, LocalEventRow, LocalException } from '../planner';
import type { Row } from '../types';
import type { CalendarEventWire } from '../wire';
import { collectionKey, exceptionRef, objectRef } from '../common/ids';
import { EventStatus } from '../android-columns';
import type { SelfContext } from './attendees';
import { eventImage, plainInstanceImage, type EventImage, type ExceptionImage, type RowImage } from './image';
import {
GroupBuilder,
assertExceptionCount,
assertRow,
deleteRow,
insertRow,
updateRow,
type RowState,
} from './rows';
import {
COLUMN_UNITS,
EXCEPTION_COLUMN_UNITS,
MASTER_COLUMN_UNITS,
attendeeDiffers,
attendeesByKey,
baselineOfImage,
columnUnitDiffers,
exdateEntries,
isLossyEditor,
remindersDiffer,
sideOfBaseline,
sideOfImage,
sideOfRow,
} from './units';
export interface CalendarTarget {
calendarRowId: number;
calendarId: string;
}
/**
* The calendar row an event lives in: the row it is already in while the
* event still belongs to that calendar, else its first selected calendar by
* id (docs/device-sync.md, "Decisions and limitations").
*/
export function pickCalendar(
event: CalendarEventWire,
local: LocalEventRow | null,
ctx: Pick<CalendarContext, 'jmapAccountId' | 'calendarRowId' | 'calendarIdOfRow' | 'isSelected'>,
): CalendarTarget | null {
const ids = Object.entries(event.calendarIds ?? {})
.filter(([, on]) => on)
.map(([id]) => id)
.sort();
const candidates = ids.filter((id) => ctx.calendarRowId(id) !== null && ctx.isSelected(collectionKey(ctx.jmapAccountId, id)));
if (local) {
const current = ctx.calendarIdOfRow(local.calendarRowId);
if (current && current.jmapAccountId === ctx.jmapAccountId && candidates.includes(current.calendarId)) {
return { calendarRowId: local.calendarRowId, calendarId: current.calendarId };
}
}
if (!candidates.length) return null;
return { calendarRowId: ctx.calendarRowId(candidates[0])!, calendarId: candidates[0] };
}
// ─── One row ────────────────────────────────────────────
export interface RowMerge {
cells: Row;
attendees: Row[];
reminders: Row[] | null;
baseline: EventBaseline;
/** Units whose local change survived (the row stays dirty). */
kept: string[];
conflicts: number;
}
export interface RowMergeInput {
isException: boolean;
current: LocalEventRow;
/** What the device last wrote (SYNC_DATA4), or an image standing in for it. */
baseline: EventBaseline;
/** The old server state's image of this row. */
base: RowImage;
/** The new server state's image of this row. */
remote: RowImage;
/** Whether the row's differences from its baseline are edits (DIRTY). */
dirty: boolean;
/** Units counted as edited even on a clean row (a split source's rule). */
forced?: ReadonlySet<string>;
/** An editor that rewrites attendee types (Fossify). */
lossy?: boolean;
self: SelfContext;
}
/** Per-unit merge of one row (see the header): what to write, the new baseline, what stays dirty. */
export function mergeRow(input: RowMergeInput): RowMerge {
const { current, baseline, base, remote, dirty } = input;
const forced = input.forced ?? new Set<string>();
const cur = sideOfRow(current);
const bl = sideOfBaseline(baseline);
const bs = sideOfImage(base);
const rm = sideOfImage(remote);
const cells: Row = { ...remote.cells };
const remoteBaseline = baselineOfImage(remote, input.isException);
const baseCells: Row = { ...remoteBaseline.cells };
const kept: string[] = [];
let conflicts = 0;
const decide = (unit: string, local: boolean, remoteChanged: boolean, same: boolean): 'local' | 'remote' => {
if (!local) return 'remote';
if (!remoteChanged) {
kept.push(unit);
return 'local';
}
if (!same) conflicts += 1;
return 'remote';
};
for (const unit of input.isException ? EXCEPTION_COLUMN_UNITS : MASTER_COLUMN_UNITS) {
const local = (dirty || forced.has(unit)) && columnUnitDiffers(unit, cur, bl);
const choice = decide(unit, local, columnUnitDiffers(unit, bs, rm), !columnUnitDiffers(unit, cur, rm));
if (choice === 'local') {
for (const c of COLUMN_UNITS[unit]) {
cells[c] = current.cells[c] ?? null;
baseCells[c] = baseline.cells[c] ?? null;
}
}
}
if (!input.isException) {
const curE = exdateEntries(cur);
const blE = exdateEntries(bl);
const bsE = exdateEntries(bs);
const rmE = exdateEntries(rm);
const final = new Set<string>();
const finalBaseline = new Set<string>();
for (const e of new Set([...curE, ...blE, ...bsE, ...rmE])) {
const choice = decide(`exdate:${e}`, dirty && curE.has(e) !== blE.has(e), bsE.has(e) !== rmE.has(e), curE.has(e) === rmE.has(e));
if (choice === 'remote') {
if (rmE.has(e)) {
final.add(e);
finalBaseline.add(e);
}
} else {
if (curE.has(e)) final.add(e);
if (blE.has(e)) finalBaseline.add(e);
}
}
const recurring = !!cells[Events.RRULE];
// Our own column text when nothing local is kept, so an echo compares equal character for character.
cells[Events.EXDATE] = !recurring || !final.size ? null : sameEntries(final, rmE) ? remote.cells[Events.EXDATE] : [...final].sort().join(',');
baseCells[Events.EXDATE] = !finalBaseline.size ? null : [...finalBaseline].sort().join(',');
}
const curA = attendeesByKey(cur.attendees, input.self);
const blA = attendeesByKey(bl.attendees, input.self);
const bsA = attendeesByKey(bs.attendees, input.self);
const rmA = attendeesByKey(rm.attendees, input.self);
const attendees: Row[] = [];
const baselineAttendees: Row[] = [];
for (const key of new Set([...rmA.keys(), ...curA.keys(), ...blA.keys(), ...bsA.keys()])) {
const local = dirty && attendeeDiffers(curA.get(key), blA.get(key), input.lossy);
const choice = decide(
`attendee:${key}`,
local,
attendeeDiffers(bsA.get(key), rmA.get(key)),
!attendeeDiffers(curA.get(key), rmA.get(key), input.lossy),
);
if (choice === 'remote') {
const r = rmA.get(key);
if (r) {
attendees.push(r);
baselineAttendees.push(r);
}
} else {
const c = curA.get(key);
const b = blA.get(key);
if (c) attendees.push(c);
if (b) baselineAttendees.push(b);
}
}
let reminders: Row[] | null = null;
let baselineReminders: Row[];
if (remote.reminders === null) {
// Bulwark owns reminders: the rows are an app's business, neither written nor uploaded.
baselineReminders = current.reminders.map((r) => r.cells);
} else {
const local = dirty && remindersDiffer(cur, bl);
const choice = decide('reminders', local, remindersDiffer(bs, rm), !remindersDiffer(cur, rm));
reminders = choice === 'remote' ? remote.reminders : current.reminders.map((r) => r.cells);
baselineReminders = choice === 'remote' ? remote.reminders : bl.reminders ?? [];
}
return {
cells,
attendees,
reminders,
baseline: { cells: baseCells, attendees: baselineAttendees, reminders: baselineReminders },
kept,
conflicts,
};
}
/** Exception rows the provider links to the master by ORIGINAL_ID (what the count assert counts). */
export function linkedExceptionCount(local: LocalEvent): number {
return local.exceptions.filter((x) => Number(x.cells[Events.ORIGINAL_ID]) === local.eventId).length;
}
function sameEntries(a: Set<string>, b: Set<string>): boolean {
return a.size === b.size && [...a].every((e) => b.has(e));
}
// ─── A whole event ──────────────────────────────────────
export type WriteMode =
/** A download: dirty rows are merged per unit. */
| 'merge'
/** After an accepted upload, a revert or a zone change: every row becomes the image, DIRTY cleared. */
| 'overwrite';
export interface EventWriteResult {
group: GroupBuilder;
conflicts: number;
stillDirty: boolean;
}
/** Whether an exception row stands for "this instance was deleted" (a DELETED row or a new cancellation). */
export function isRemovedInstance(row: LocalEventRow): boolean {
if (row.deleted) return true;
if (Number(row.cells[Events.STATUS]) !== EventStatus.CANCELED) return false;
// A cancellation the row already had when device sync wrote it is the server's own status.
return !row.baseline || Number(row.baseline.cells[Events.STATUS]) !== EventStatus.CANCELED;
}
export interface EventWriteOptions {
mode: WriteMode;
ref: string;
ctx: CalendarContext;
/** The old server state the rows were written from; null: adopt (the rows' changes are measured against `event`). */
base: CalendarEventWire | null;
/** SYNC_DATA3 to write; undefined leaves it. */
pending?: null;
/** SYNC_DATA5 to write; undefined leaves it. */
clearPoison?: boolean;
}
/**
* The ops that bring a local event (master and exceptions) to `event`,
* merged per `options.mode`. `image` is `event`'s image in its calendar.
*/
export function writeEvent(
event: CalendarEventWire,
image: EventImage,
target: CalendarTarget,
local: LocalEvent,
options: EventWriteOptions,
): EventWriteResult {
const { ctx } = options;
const group = new GroupBuilder(options.ref);
const overwrite = options.mode === 'overwrite';
const masterRef = objectRef(ctx.jmapAccountId, event.id);
const uid = typeof event.uid === 'string' && event.uid ? event.uid : null;
const baseEvent = options.base ?? event;
const baseTarget = options.base ? { calendarRowId: local.calendarRowId, calendarId: ctx.calendarIdOfRow(local.calendarRowId)?.calendarId ?? target.calendarId } : target;
const baseImage = options.base ? eventImage(baseEvent, baseTarget.calendarRowId, baseTarget.calendarId, ctx) ?? image : image;
const lossy = isLossyEditor(local.mutators);
const itemDirty = local.dirty || local.exceptions.some((x) => x.dirty || x.deleted);
// Asserts first: the rows as read.
assertRow(group, local, false, itemDirty);
for (const x of local.exceptions) assertRow(group, x, true, itemDirty);
assertExceptionCount(group, local.eventId, linkedExceptionCount(local));
let conflicts = 0;
let stillDirty = false;
const masterMerge = mergeRow({
isException: false,
current: local,
baseline: options.base && local.baseline ? local.baseline : baselineOfImage(options.base ? baseImage.master : image.master, false),
base: baseImage.master,
remote: image.master,
dirty: !overwrite && local.dirty,
// A CONTENT_EXCEPTION_URI split capped this rule without DIRTY.
forced: !overwrite && local.split === 'source' ? new Set(['rule']) : undefined,
lossy,
self: ctx,
});
conflicts += masterMerge.conflicts;
if (masterMerge.kept.length) stillDirty = true;
const masterState: RowState = {
isException: false,
cells: masterMerge.cells,
attendees: masterMerge.attendees,
reminders: masterMerge.reminders,
baseline: masterMerge.baseline,
syncId: masterRef,
uid,
shadow: event,
...(options.pending === null ? { pending: null } : {}),
...(options.clearPoison ? { poison: null } : {}),
clearDirty: overwrite || (local.dirty && !masterMerge.kept.length),
hasParticipants: image.master.hasParticipants,
};
const deletes: LocalException[] = [];
const updates: Array<{ row: LocalEventRow; state: RowState }> = [];
const inserts: RowState[] = [];
const byKey = new Map<string, LocalException[]>();
for (const x of local.exceptions) {
if (!x.recurrenceId) continue;
const list = byKey.get(x.recurrenceId) ?? [];
list.push(x);
byKey.set(x.recurrenceId, list);
}
const keys = new Set<string>([...byKey.keys(), ...image.exceptions.keys(), ...baseImage.exceptions.keys()]);
const finalCalendarRowId = Number(masterMerge.cells[Events.CALENDAR_ID]);
const exceptionState = (key: string, merged: { cells: Row; attendees: Row[]; reminders: Row[] | null; baseline: EventBaseline }, dirtyLeft: boolean, row: LocalEventRow | null, ours: boolean): RowState => ({
isException: true,
cells: { ...merged.cells, [Events.CALENDAR_ID]: finalCalendarRowId },
attendees: merged.attendees,
reminders: merged.reminders,
baseline: { ...merged.baseline, cells: { ...merged.baseline.cells, [Events.CALENDAR_ID]: finalCalendarRowId } },
syncId: ours ? exceptionRef(masterRef, key) : row?.syncId ?? null,
uid,
recurrenceKey: ours ? key : ((row?.cells[Events.SYNC_DATA2] as string | null | undefined) ?? null),
originalSyncId: masterRef,
clearDirty: !!row?.dirty && !dirtyLeft,
hasParticipants: merged.attendees.length > 0,
});
for (const key of [...keys].sort()) {
const rows = byKey.get(key) ?? [];
const [row, ...duplicates] = rows;
// Two rows for one instance (an app's own duplicate): the extra ones go when clean.
for (const extra of duplicates) {
if (overwrite || !(extra.dirty || extra.deleted)) deletes.push(extra);
else stillDirty = true;
}
const r = image.exceptions.get(key);
const b = baseImage.exceptions.get(key);
if (!row) {
if (r) inserts.push(imageState(r, finalCalendarRowId, masterRef, uid));
continue;
}
if (overwrite && row.deleted) {
// Its exclusion was uploaded (or is being reverted): a soft-deleted row is purged either way.
deletes.push(row);
if (r) inserts.push(imageState(r, finalCalendarRowId, masterRef, uid));
continue;
}
const rowDirty = !overwrite && (row.dirty || row.deleted);
if (!rowDirty) {
if (r) updates.push({ row, state: exceptionState(key, { ...r, baseline: baselineOfImage(r, true) }, false, row, true) });
else deletes.push(row);
continue;
}
if (isRemovedInstance(row)) {
// The device deleted this instance: that wins over a server edit and uploads as an exclusion.
stillDirty = true;
continue;
}
if ((b && !r) || image.excluded.has(key)) {
// The server removed or excluded the instance's override: the server delete wins.
deletes.push(row);
conflicts += 1;
continue;
}
const remoteSide = r ?? plainInstanceImage(event, key, finalCalendarRowId, target.calendarId, ctx);
const baseSide = b ?? plainInstanceImage(baseEvent, key, baseTarget.calendarRowId, baseTarget.calendarId, ctx) ?? remoteSide;
if (!remoteSide || !baseSide) {
stillDirty = true;
continue;
}
const merged = mergeRow({
isException: true,
current: row,
baseline: row.baseline ?? baselineOfImage(baseSide, true),
base: baseSide,
remote: remoteSide,
dirty: true,
lossy,
self: ctx,
});
conflicts += merged.conflicts;
// A local exception the server has no override for keeps its local identity until it is uploaded.
const left = merged.kept.length > 0 || !r;
if (left) stillDirty = true;
updates.push({ row, state: exceptionState(key, merged, left, row, !!r) });
}
for (const row of deletes) deleteRow(group, row.eventId);
updateRow(group, local, masterState);
for (const { row, state } of updates) updateRow(group, row, state);
for (const state of inserts) insertRow(group, state, { id: local.eventId });
return { group, conflicts, stillDirty: !overwrite && (stillDirty || local.deleted) };
}
/** The row an exception image becomes, written clean. */
export function imageState(x: ExceptionImage, calendarRowId: number, masterRef: string, uid: string | null): RowState {
const cells = { ...x.cells, [Events.CALENDAR_ID]: calendarRowId };
const baseline = baselineOfImage({ ...x, cells }, true);
return {
isException: true,
cells,
attendees: x.attendees,
reminders: x.reminders,
baseline,
syncId: exceptionRef(masterRef, x.key),
uid,
recurrenceKey: x.key,
originalSyncId: masterRef,
hasParticipants: x.hasParticipants,
};
}
/** A new event's rows: the master and one row per exception, attendees and reminders included. */
export function insertEvent(event: CalendarEventWire, image: EventImage, ctx: CalendarContext, ref: string): GroupBuilder {
const group = new GroupBuilder(ref);
const masterRef = objectRef(ctx.jmapAccountId, event.id);
const uid = typeof event.uid === 'string' && event.uid ? event.uid : null;
const master = insertRow(group, {
isException: false,
cells: image.master.cells,
attendees: image.master.attendees,
reminders: image.master.reminders,
baseline: baselineOfImage(image.master, false),
syncId: masterRef,
uid,
shadow: event,
hasParticipants: image.master.hasParticipants,
});
for (const key of [...image.exceptions.keys()].sort()) {
const x = image.exceptions.get(key)!;
insertRow(group, imageState(x, Number(image.master.cells[Events.CALENDAR_ID]), masterRef, uid), { ref: master });
}
return group;
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Deletions and inserts that are one edit (docs/device-sync.md, "Uploads",
* "Pairs"). Etar moves an event to another calendar, and turns a series into
* a single event, by deleting the row and inserting a copy. A deleted row
* and a new row of the same run whose projections match apart from the
* calendar (or the rule) upload as a patch of the existing object, so its
* uid, participants and history stay; the new row then takes the old one's
* identity. Across JMAP accounts nothing pairs: that move is a create in the
* target account and a destroy in the source.
*/
import { Attendees, Events } from '../android-columns';
import type { CalendarContext, LocalEvent, LocalEventRow, OpGroup } from '../planner';
import type { Row } from '../types';
import type { CalendarEventWire } from '../wire';
import { exceptionRef, parseObjectRef } from '../common/ids';
import { applyPatch, type PatchObject } from '../common/patch';
import { EXCEPTION_CELLS, MASTER_CELLS, encodeBaseline, makeBaseline } from './columns';
import { durationTextSeconds } from './duration';
import { isExcluded, overridesOf } from './exceptions';
import { GroupBuilder, assertRow, deleteRow, updateRow } from './rows';
import { rruleParts } from './rrule';
import { sendsSchedulingMessages } from './scheduling';
import { put } from './upload';
/** What must be equal for two rows to be one event (the calendar and the rule aside). */
const PAIR_COLUMNS = [
Events.TITLE,
Events.DESCRIPTION,
Events.EVENT_LOCATION,
Events.STATUS,
Events.AVAILABILITY,
Events.ACCESS_LEVEL,
Events.EVENT_COLOR,
Events.DTSTART,
Events.ALL_DAY,
];
const num = (v: unknown) => (v === null || v === undefined || v === '' ? null : Number(v));
function lengthMs(cells: Row): number | null {
const dtstart = num(cells[Events.DTSTART]);
const dtend = num(cells[Events.DTEND]);
if (dtstart !== null && dtend !== null) return dtend - dtstart;
const seconds = durationTextSeconds(cells[Events.DURATION]);
return seconds === null ? null : seconds * 1000;
}
const attendeeSet = (row: LocalEventRow) =>
row.attendees.map((a) => String(a.cells[Attendees.ATTENDEE_EMAIL] ?? '').toLowerCase()).sort().join(',');
export interface PairKind {
/** The same event in another calendar. */
move: boolean;
/** A series turned into a single event. */
unrecur: boolean;
}
/** How a deleted and a new row are one edit, or null. */
export function pairKind(deleted: LocalEvent, fresh: LocalEvent): PairKind | null {
for (const c of PAIR_COLUMNS) {
if (String(deleted.cells[c] ?? '') !== String(fresh.cells[c] ?? '')) return null;
}
const allDay = Number(fresh.cells[Events.ALL_DAY] ?? 0) === 1;
if (!allDay && String(deleted.cells[Events.EVENT_TIMEZONE] ?? '') !== String(fresh.cells[Events.EVENT_TIMEZONE] ?? '')) return null;
if (lengthMs(deleted.cells) !== lengthMs(fresh.cells)) return null;
if (attendeeSet(deleted) !== attendeeSet(fresh)) return null;
const move = deleted.calendarRowId !== fresh.calendarRowId;
const deletedRule = rruleParts(deleted.cells[Events.RRULE]);
const freshRule = rruleParts(fresh.cells[Events.RRULE]);
const unrecur = !!deletedRule && !freshRule;
if (!unrecur && String(deleted.cells[Events.RRULE] ?? '') !== String(fresh.cells[Events.RRULE] ?? '')) return null;
return move || unrecur ? { move, unrecur } : null;
}
/** The patch a pair uploads: the calendar membership swapped, the rule and its overrides removed. */
export function pairPatch(
deleted: LocalEvent,
fresh: LocalEvent,
kind: PairKind,
ctx: CalendarContext,
): { patch: PatchObject; sendSchedulingMessages: boolean } | null {
const shadow = deleted.shadow;
if (!parseObjectRef(deleted.syncId) || !shadow) return null;
const patch: PatchObject = {};
if (kind.move) {
const from = ctx.calendarIdOfRow(deleted.calendarRowId);
const to = ctx.calendarIdOfRow(fresh.calendarRowId);
if (!from || !to || from.jmapAccountId !== ctx.jmapAccountId || to.jmapAccountId !== ctx.jmapAccountId) return null;
put(patch, ['calendarIds', from.calendarId], null);
put(patch, ['calendarIds', to.calendarId], true);
}
if (kind.unrecur) {
put(patch, ['recurrenceRule'], null);
if (Object.keys(overridesOf(shadow)).length) put(patch, ['recurrenceOverrides'], null);
}
// A move alone tells nobody; a series ending its recurrence does.
const scheduling = kind.unrecur && sendsSchedulingMessages(shadow, { kind: 'update', notifying: true, rsvp: false, addsAttendees: false }, ctx);
return { patch, sendSchedulingMessages: scheduling };
}
/**
* Applied once the pair's patch is accepted: the old row and its exception
* rows go, the new row takes the identity (its copied exception rows the
* overrides that still exist), and the shadow is the old one with the patch
* applied until the next download brings the server's.
*/
export function pairOps(deleted: LocalEvent, fresh: LocalEvent, patch: PatchObject): OpGroup {
const syncId = deleted.syncId!;
const shadow: CalendarEventWire = applyPatch(deleted.shadow!, patch) ?? deleted.shadow!;
const group = new GroupBuilder(syncId);
assertRow(group, deleted, false, false);
assertRow(group, fresh, false, true);
for (const x of fresh.exceptions) assertRow(group, x, true, true);
for (const x of deleted.exceptions) deleteRow(group, x.eventId);
deleteRow(group, deleted.eventId);
const children = (row: LocalEventRow) => ({ attendees: row.attendees.map((a) => a.cells), reminders: row.reminders.map((r) => r.cells) });
const cells: Row = {};
for (const c of MASTER_CELLS) cells[c] = fresh.cells[c] ?? null;
updateRow(
group,
{ ...fresh, shadow: null },
{
isException: false,
cells,
...children(fresh),
baseline: makeBaseline(fresh.cells, MASTER_CELLS, children(fresh).attendees, children(fresh).reminders),
syncId,
uid: typeof shadow.uid === 'string' ? shadow.uid : null,
shadow,
pending: null,
clearDirty: true,
hasParticipants: fresh.attendees.length > 0,
},
);
const overrides = overridesOf(shadow);
for (const x of fresh.exceptions) {
const key = x.recurrenceId;
const values: Row = { [Events.ORIGINAL_SYNC_ID]: syncId };
if (key && overrides[key] && !isExcluded(overrides[key])) {
Object.assign(values, {
[Events._SYNC_ID]: exceptionRef(syncId, key),
[Events.SYNC_DATA2]: key,
[Events.SYNC_DATA4]: encodeBaseline(makeBaseline(x.cells, EXCEPTION_CELLS, children(x).attendees, children(x).reminders)),
[Events.DIRTY]: 0,
});
}
group.write({ op: 'update', table: 'events', id: x.eventId, values, expectCount: 1 });
}
return group.build();
}
+463
View File
@@ -0,0 +1,463 @@
/**
* The calendar planner (docs/device-sync.md, "Calendar mapping"): JSCalendar
* events and calendars ↔ CalendarContract rows, pure. The engine owns all
* I/O; this module turns rows and server objects into op groups and upload
* actions, following the contract in ../planner.ts.
*
* Tasks never reach CalendarContract: the engine filters them, and every
* entry point here refuses one as well.
*/
import { Events } from '../android-columns';
import type {
AcceptedPlan,
CalendarContext,
CalendarPlanner,
DownloadPlan,
LocalEvent,
LocalEventRow,
OpGroup,
PoisonMarker,
UploadAction,
UploadPlan,
} from '../planner';
import type { CalendarEventWire } from '../wire';
import { objectRef, parseObjectRef, pendingUidOf } from '../common/ids';
import { deepEqual, jsonHash } from '../common/json';
import { isTaskLikeObject } from '../../lib/calendar-component-detection';
import { keepDirtyGroup, type UploadedUnits } from './accepted';
import { ATTENDEE_COLUMNS, CALENDAR_COLUMNS, EVENT_COLUMNS, EXCEPTION_CELLS, MASTER_CELLS, REMINDER_COLUMNS, isTruncatedBaseline, makeBaseline } from './columns';
import { planCalendars } from './calendars';
import { claimOps, claimUid, claimedUid, computeCreate, createTarget, deleteActions } from './create';
import { decodeCalendar, decodeEvents, isExceptionRow, isNewMaster } from './decode';
import { isExcluded, overridesOf } from './exceptions';
import { eventImage } from './image';
import { insertEvent, isRemovedInstance, linkedExceptionCount, pickCalendar, writeEvent } from './merge';
import { pairKind, pairOps, pairPatch } from './pairs';
import { GroupBuilder, assertExceptionCount, assertRow, deleteRow, writesOf } from './rows';
import { eventZone } from './timing';
import { columnUnitDiffers, remindersDiffer, sideOfBaseline, sideOfRow } from './units';
import { SKIP, SkipUpload, computeUpdate, type UploadComputation } from './upload';
type Plan = UploadPlan<CalendarEventWire>;
function refuseTask(event: CalendarEventWire | null | undefined): void {
if (event && isTaskLikeObject(event)) {
throw new Error('calendar planner: tasks are not synced to the device (filter them before planning)');
}
}
function refOf(local: LocalEventRow | null, event?: CalendarEventWire, jmapAccountId?: string): string {
if (local?.syncId) return local.syncId;
if (event && jmapAccountId) return objectRef(jmapAccountId, event.id);
return local ? `row:${local.eventId}` : 'new';
}
// ─── Downloads ──────────────────────────────────────────
function planDownload(event: CalendarEventWire, local: LocalEvent | null, ctx: CalendarContext): DownloadPlan {
refuseTask(event);
refuseTask(local?.shadow);
const ref = refOf(local, event, ctx.jmapAccountId);
const none = (stillDirty: boolean): DownloadPlan => ({ ops: { ref, ops: [] }, conflicts: 0, stillDirty, effect: 'none', writes: 0 });
const target = pickCalendar(event, local, ctx);
if (!target) throw new Error(`calendar planner: ${ref} is in no synced calendar (the engine removes such rows)`);
const image = eventImage(event, target.calendarRowId, target.calendarId, ctx);
// Stalwart drops a start it can't resolve (a DST gap or overlap): nothing to show.
if (!image) return none(!!local && (local.dirty || local.deleted));
if (!local) {
const ops = insertEvent(event, image, ctx, ref).build();
return { ops, conflicts: 0, stillDirty: false, effect: 'insert', writes: writesOf(ops) };
}
// A split clone only looks like this object: it is a new event and uploads as one. The source row is the object's.
if (local.split === 'clone') return none(true);
// A different object under a reused id: its rows are replaced, never patched.
const uidChanged = !!local.shadow && typeof local.shadow.uid === 'string' && typeof event.uid === 'string' && local.shadow.uid !== event.uid;
if (uidChanged) {
const group = new GroupBuilder(ref);
for (const x of local.exceptions) deleteRow(group, x.eventId);
deleteRow(group, local.eventId);
const inserted = insertEvent(event, image, ctx, ref).build().ops;
const offset = group.writeCount;
for (const op of inserted) {
if (op.op === 'insert' && op.refs) {
const refs = Object.fromEntries(Object.entries(op.refs).map(([k, v]) => [k, v + offset]));
group.write({ ...op, refs });
} else group.write(op);
}
const ops = group.build();
return { ops, conflicts: 0, stillDirty: false, effect: 'update', writes: writesOf(ops) };
}
if (local.deleted) {
// Deleted on the device: that wins over a server edit, so only the shadow follows, for the destroy.
if (!local.shadow || deepEqual(local.shadow, event)) return none(true);
const group = new GroupBuilder(ref);
group.assert({ op: 'assert', table: 'events', id: local.eventId, values: { [Events.DELETED]: 1, [Events._SYNC_ID]: local.syncId }, expectCount: 1 });
group.write({ op: 'update', table: 'events', id: local.eventId, values: { [Events.SYNC_DATA1]: JSON.stringify(event) }, expectCount: 1 });
const ops = group.build();
return { ops, conflicts: 0, stillDirty: true, effect: 'update', writes: writesOf(ops) };
}
const itemDirty = local.dirty || local.split === 'source' || local.exceptions.some((x) => x.dirty || x.deleted);
const result = writeEvent(event, image, target, local, {
mode: 'merge',
ref,
ctx,
// No shadow: our own create whose identity was never written. Adopt it; its rows count as edits of `event`.
base: local.shadow,
...(local.shadow ? {} : { pending: null }),
clearPoison: !itemDirty,
});
const ops = result.group.build();
const writes = writesOf(ops);
return {
ops: writes ? ops : { ref, ops: [] },
conflicts: result.conflicts,
stillDirty: result.stillDirty,
effect: writes ? 'update' : 'none',
writes,
};
}
function planLocalDelete(local: LocalEvent): OpGroup {
const group = new GroupBuilder(refOf(local));
assertExceptionCount(group, local.eventId, linkedExceptionCount(local));
for (const x of local.exceptions) deleteRow(group, x.eventId);
deleteRow(group, local.eventId);
return group.build();
}
/**
* Rewrites the baselines of clean rows that drifted from them (the provider
* normalised a write, or a crash hit between a chunk and its read-back),
* behind DIRTY=0. A recurring master's timing and rule are never healed: a
* CONTENT_EXCEPTION_URI split changes them without DIRTY.
*/
function planBaselineHeal(local: LocalEvent): OpGroup | null {
if (local.split || local.deleted || isExceptionRow(local.cells)) return null;
const group = new GroupBuilder(refOf(local));
const heals: Array<{ row: LocalEventRow; isException: boolean; baseline: ReturnType<typeof makeBaseline> }> = [];
const consider = (row: LocalEventRow, isException: boolean) => {
if (row.dirty || row.deleted) return;
const columns = isException ? EXCEPTION_CELLS : MASTER_CELLS;
const truncated = isTruncatedBaseline(row.baseline?.cells[Events.DESCRIPTION]) && !columnUnitDiffers('description', sideOfRow(row), sideOfBaseline(row.baseline!));
const next = makeBaseline(row.cells, columns, row.attendees.map((a) => a.cells), row.reminders.map((r) => r.cells), truncated);
if (truncated) next.cells[Events.DESCRIPTION] = row.baseline!.cells[Events.DESCRIPTION];
const recurring = !isException && (!!row.cells[Events.RRULE] || !!row.baseline?.cells[Events.RRULE]);
if (recurring && row.baseline) {
for (const c of [Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY, Events.RRULE]) {
next.cells[c] = row.baseline.cells[c] ?? null;
}
}
if (row.baseline && deepEqual(next, row.baseline)) return;
heals.push({ row, isException, baseline: next });
};
consider(local, false);
for (const x of local.exceptions) consider(x, true);
if (!heals.length) return null;
for (const h of heals) assertRow(group, h.row, h.isException, false);
for (const h of heals) {
group.write({ op: 'update', table: 'events', id: h.row.eventId, values: { [Events.SYNC_DATA4]: JSON.stringify({ v: 1, ...h.baseline }) }, expectCount: 1 });
}
return group.build();
}
// ─── Uploads ────────────────────────────────────────────
/**
* The fingerprint a poison marker records for an event: what it would upload
* from (its flags, shadow, rows, attendees, reminders and exceptions), so the
* back-off holds while nothing changes and lifts when the user edits it. The
* engine writes the markers and checks them before planning
* (engine/calendar-sync.ts); this is the same content, so an item handed in
* anyway is still skipped.
*/
export function poisonFingerprint(e: LocalEvent): string {
return jsonHash({
deleted: e.deleted,
shadow: e.shadow,
cells: e.cells,
attendees: e.attendees.map((a) => JSON.stringify(a.cells)).sort(),
reminders: e.reminders.map((r) => JSON.stringify(r.cells)).sort(),
exceptions: e.exceptions
.map((x) => JSON.stringify([x.deleted, x.cells, x.attendees.map((a) => a.cells), x.reminders.map((r) => r.cells)]))
.sort(),
});
}
/** Writes a poison marker (SYNC_DATA5) on the master. */
export function poisonOps(local: LocalEvent, marker: PoisonMarker): OpGroup {
return {
ref: refOf(local),
ops: [{ op: 'update', table: 'events', id: local.eventId, values: { [Events.SYNC_DATA5]: JSON.stringify(marker) }, expectCount: 1 }],
};
}
function activePoison(local: LocalEvent, ctx: CalendarContext): PoisonMarker | null {
const p = local.poison;
if (!p || ctx.now >= p.until) return null;
return p.fp === poisonFingerprint(local) ? p : null;
}
/** Deletes a master and its exceptions (a purge, a revert that refetches). */
function purgeGroup(local: LocalEvent): OpGroup {
return planLocalDelete(local);
}
/** Rows rewritten from the shadow, DIRTY cleared: a read-only event's device edits are undone. */
function revertGroup(local: LocalEvent, ctx: CalendarContext): OpGroup | null {
const shadow = local.shadow;
if (!shadow) return null;
const target = pickCalendar(shadow, local, ctx) ?? (() => {
const cal = ctx.calendarIdOfRow(local.calendarRowId);
return cal ? { calendarRowId: local.calendarRowId, calendarId: cal.calendarId } : null;
})();
if (!target) return null;
const image = eventImage(shadow, target.calendarRowId, target.calendarId, ctx);
if (!image) return null;
return writeEvent(shadow, image, target, local, { mode: 'overwrite', ref: refOf(local), ctx, base: shadow }).group.build();
}
/** Clears DIRTY where nothing uploadable changed, taking the rows as they are as the new baselines. */
function cleanGroup(local: LocalEvent, computation: UploadComputation | null): OpGroup {
const group = new GroupBuilder(refOf(local));
assertRow(group, local, false, true);
for (const x of local.exceptions) assertRow(group, x, true, true);
const settle = (row: LocalEventRow, isException: boolean) => {
if (!row.dirty) return;
const truncated = isTruncatedBaseline(row.baseline?.cells[Events.DESCRIPTION]);
const baseline = makeBaseline(row.cells, isException ? EXCEPTION_CELLS : MASTER_CELLS, row.attendees.map((a) => a.cells), row.reminders.map((r) => r.cells), truncated);
group.write({ op: 'update', table: 'events', id: row.eventId, values: { [Events.DIRTY]: 0, [Events.SYNC_DATA4]: JSON.stringify({ v: 1, ...baseline }) }, expectCount: 1 });
};
settle(local, false);
const overrides = overridesOf(local.shadow);
for (const x of local.exceptions) {
const excludedAlready = !!x.recurrenceId && isExcluded(overrides[x.recurrenceId]);
if ((computation?.removedExceptions.has(x.eventId) || isRemovedInstance(x)) && excludedAlready) {
deleteRow(group, x.eventId);
continue;
}
settle(x, true);
}
return group.build();
}
function planUpload(local: LocalEvent, ctx: CalendarContext): Plan {
refuseTask(local.shadow);
// An exception row whose master is gone never uploads as an event of its own.
if (isExceptionRow(local.cells)) return { kind: 'purge', ops: purgeGroup(local) };
const calendar = ctx.calendarIdOfRow(local.calendarRowId);
if (!calendar) return { kind: 'skip', reason: SKIP.notOurCalendar };
const isNew = local.split === 'clone' || (!local.shadow && isNewMaster(local));
if (local.deleted) {
if (isNew) {
// Never created (or its outcome unknown): destroy whatever carries the pending uid, else just purge.
const uid = local.split === 'clone' ? null : local.pending?.uid ?? pendingUidOf(local.syncId);
if (!uid) return { kind: 'purge', ops: purgeGroup(local) };
return { kind: 'upload', actions: [{ kind: 'destroy', id: null, uid }] };
}
if (!parseObjectRef(local.syncId)) return { kind: 'purge', ops: purgeGroup(local) };
if (ctx.isReadOnly(calendar.calendarId)) return { kind: 'revert', ops: purgeGroup(local), refetch: true };
return { kind: 'upload', actions: deleteActions(local, ctx) };
}
if (isNew) {
if (ctx.isReadOnly(calendar.calendarId)) return { kind: 'revert', ops: purgeGroup(local) };
const uid = claimedUid(local);
const target = createTarget(local, ctx);
if (!target) return { kind: 'skip', reason: SKIP.notOurCalendar };
if (!uid) return { kind: 'claim', ops: claimOps(local, claimUid(local, ctx), target.key) };
const poison = activePoison(local, ctx);
if (poison) return { kind: 'skip', reason: poison.type };
try {
const create = computeCreate(local, uid, target.calendarId, ctx);
const action: UploadAction<CalendarEventWire> = { kind: 'create', uid, collectionId: target.calendarId, object: create.object };
if (create.sendSchedulingMessages) action.sendSchedulingMessages = true;
return { kind: 'upload', actions: [action] };
} catch (e) {
if (e instanceof SkipUpload) return { kind: 'skip', reason: e.reason };
throw e;
}
}
const ref = parseObjectRef(local.syncId);
const shadow = local.shadow;
if (!ref || !shadow) return { kind: 'skip', reason: 'noShadow' };
const poison = activePoison(local, ctx);
if (poison) return { kind: 'skip', reason: poison.type };
// An instance Stalwart holds without its series can't be changed on its own.
if (shadow.recurrenceId && !shadow.recurrenceRule) {
const ops = revertGroup(local, ctx);
return ops ? { kind: 'revert', ops } : { kind: 'skip', reason: SKIP.instanceOnly };
}
let computation: UploadComputation;
try {
computation = computeUpdate(local, ctx);
} catch (e) {
if (e instanceof SkipUpload) return { kind: 'skip', reason: e.reason };
throw e;
}
const keys = Object.keys(computation.patch);
const readOnly = ctx.isReadOnly(calendar.calendarId);
const movedToReadOnly = keys.some((k) => {
const m = /^calendarIds\/(.+)$/.exec(k);
return m && computation.patch[k] === true && ctx.isReadOnly(m[1]);
});
if ((readOnly && keys.length) || movedToReadOnly) {
// RSVP is the one change a calendar without write rights may take.
const rsvpOnly = keys.length > 0 && keys.every((k) => /^participants\/[^/]+\/participationStatus$/.test(k));
if (!(rsvpOnly && ctx.calendar(calendar.calendarId)?.myRights?.mayRSVP && !movedToReadOnly)) {
const ops = revertGroup(local, ctx);
return ops ? { kind: 'revert', ops } : { kind: 'skip', reason: 'readOnly' };
}
}
if (!keys.length) {
if (computation.dropped.some((u) => u === 'timing' || u === 'rule')) return { kind: 'skip', reason: SKIP.ruleNotRepresentable };
return { kind: 'clean', ops: cleanGroup(local, computation) };
}
const action: UploadAction<CalendarEventWire> = { kind: 'update', id: ref.id, patch: computation.patch };
if (computation.sendSchedulingMessages) action.sendSchedulingMessages = true;
return { kind: 'upload', actions: [action] };
}
function planAccepted(local: LocalEvent, server: CalendarEventWire, ctx: CalendarContext): AcceptedPlan {
refuseTask(server);
const target = pickCalendar(server, local, ctx);
if (!target) {
// Moved out of every synced calendar (a membership removal): the rows go.
const ops = purgeGroup(local);
return { ops, keepDirtyOps: ops };
}
const wasNew = local.split === 'clone' || (!local.shadow && isNewMaster(local));
let uploaded: UploadedUnits;
if (wasNew || !local.shadow) {
uploaded = { all: true, master: new Set(), exceptions: new Map(), removed: new Set(local.exceptions.filter(isRemovedInstance).map((x) => x.eventId)) };
} else {
try {
const c = computeUpdate(local, ctx);
uploaded = { all: false, master: c.masterUnits, exceptions: c.exceptionUnits, removed: c.removedExceptions };
} catch {
uploaded = { all: false, master: new Set(), exceptions: new Map(), removed: new Set() };
}
}
const keepDirtyOps = keepDirtyGroup(local, server, uploaded, ctx);
const image = eventImage(server, target.calendarRowId, target.calendarId, ctx);
if (!image) return { ops: keepDirtyOps, keepDirtyOps };
const ops = writeEvent(server, image, target, local, {
mode: 'overwrite',
ref: objectRef(ctx.jmapAccountId, server.id),
ctx,
base: local.shadow ?? server,
pending: null,
clearPoison: true,
}).group.build();
return { ops, keepDirtyOps };
}
function planPairs(
deleted: LocalEvent[],
fresh: LocalEvent[],
ctx: CalendarContext,
): Array<{ deleted: LocalEvent; fresh: LocalEvent; actions: UploadAction<CalendarEventWire>[]; ops: OpGroup }> {
const pairs: Array<{ deleted: LocalEvent; fresh: LocalEvent; actions: UploadAction<CalendarEventWire>[]; ops: OpGroup }> = [];
const used = new Set<number>();
const inAccount = (row: LocalEventRow) => ctx.calendarIdOfRow(row.calendarRowId)?.jmapAccountId === ctx.jmapAccountId;
for (const d of deleted) {
const ref = parseObjectRef(d.syncId);
if (!ref || !d.shadow || !d.deleted || d.split || !inAccount(d)) continue;
refuseTask(d.shadow);
for (const f of fresh) {
if (used.has(f.eventId) || f.syncId !== null || f.pending || f.deleted || isExceptionRow(f.cells) || !inAccount(f)) continue;
const kind = pairKind(d, f);
if (!kind) continue;
const pair = pairPatch(d, f, kind, ctx);
if (!pair) continue;
const action: UploadAction<CalendarEventWire> = { kind: 'update', id: ref.id, patch: pair.patch };
if (pair.sendSchedulingMessages) action.sendSchedulingMessages = true;
pairs.push({ deleted: d, fresh: f, actions: [action], ops: pairOps(d, f, pair.patch) });
used.add(f.eventId);
break;
}
}
return pairs;
}
// ─── Device zone and reminder owner ─────────────────────
function planZoneChange(local: LocalEvent, previousZone: string, ctx: CalendarContext): OpGroup | null {
const shadow = local.shadow;
if (!shadow || local.split || local.deleted || local.dirty || local.exceptions.some((x) => x.dirty || x.deleted)) return null;
refuseTask(shadow);
if (previousZone === ctx.deviceZone || !eventZone(shadow, ctx.deviceZone).floating) return null;
const target = pickCalendar(shadow, local, ctx);
if (!target) return null;
const image = eventImage(shadow, target.calendarRowId, target.calendarId, ctx);
if (!image) return null;
const ops = writeEvent(shadow, image, target, local, { mode: 'overwrite', ref: refOf(local), ctx, base: shadow }).group.build();
return writesOf(ops) ? ops : null;
}
function planReminderOwnerChange(local: LocalEvent, ctx: CalendarContext): OpGroup | null {
const shadow = local.shadow;
if (!shadow || local.deleted || isExceptionRow(local.cells)) return null;
refuseTask(shadow);
const target = pickCalendar(shadow, local, ctx);
if (!target) return null;
const image = eventImage(shadow, target.calendarRowId, target.calendarId, { ...ctx, reminderOwner: 'device' });
if (!image) return null;
const rows: Array<{ row: LocalEventRow; isException: boolean; wanted: LocalEventRow['cells'][] }> = [];
const add = (row: LocalEventRow, isException: boolean, wanted: LocalEventRow['cells'][] | null) => {
if (!wanted) return;
// A reminder edit that has not reached the server yet is not overwritten.
if (row.dirty && row.baseline && remindersDiffer(sideOfRow(row), sideOfBaseline(row.baseline))) return;
rows.push({ row, isException, wanted: ctx.reminderOwner === 'device' ? wanted : [] });
};
add(local, false, image.master.reminders);
for (const x of local.exceptions) {
if (x.deleted || !x.recurrenceId) continue;
const xi = image.exceptions.get(x.recurrenceId);
add(x, true, xi ? xi.reminders : null);
}
const changing = rows.filter(({ row, wanted }) => {
const have = row.reminders.map((r) => r.cells);
return have.length !== wanted.length || remindersDiffer({ cells: {}, attendees: [], reminders: have }, { cells: {}, attendees: [], reminders: wanted });
});
if (!changing.length) return null;
const group = new GroupBuilder(refOf(local));
for (const { row, isException } of changing) assertRow(group, row, isException, true);
for (const { row, isException, wanted } of changing) {
for (const r of row.reminders) group.write({ op: 'delete', table: 'reminders', id: r.id, expectCount: 1 });
for (const w of wanted) group.write({ op: 'insert', table: 'reminders', values: { ...w, event_id: row.eventId } });
const baseline = row.baseline ?? makeBaseline(row.cells, isException ? EXCEPTION_CELLS : MASTER_CELLS, row.attendees.map((a) => a.cells), []);
group.write({
op: 'update',
table: 'events',
id: row.eventId,
values: { [Events.SYNC_DATA4]: JSON.stringify({ v: 1, ...baseline, reminders: wanted }) },
expectCount: 1,
});
}
return group.build();
}
export const calendarPlanner: CalendarPlanner = {
calendarColumns: CALENDAR_COLUMNS,
eventColumns: EVENT_COLUMNS,
attendeeColumns: ATTENDEE_COLUMNS,
reminderColumns: REMINDER_COLUMNS,
decodeCalendar,
decodeEvents,
planCalendars,
planDownload,
planLocalDelete,
planBaselineHeal,
planUpload,
planAccepted,
planPairs,
planZoneChange,
planReminderOwnerChange,
};
+153
View File
@@ -0,0 +1,153 @@
/**
* Alerts ↔ Reminders rows (docs/device-sync.md, "Other properties" and
* "Reminder owner").
*
* A Reminders row is "minutes before the start" plus a method, so only an
* offset alert relative to the start (or with no `relativeTo`) that fires at
* or before it is shown; alerts relative to the end, absolute alerts and
* alerts after the start stay on the server. The app's
* `offsetToMinutesBefore` rejects the valid `+PT15M` (and fractions), so
* offsets are read with this module's own duration parser.
*/
import type { Alert } from '../../api/types';
import { Reminders } from '../android-columns';
import type { CalendarLike } from '../wire';
import type { Row } from '../types';
import { minutesBeforeToOffset } from '../../lib/calendar-alerts';
import { durationSeconds, parseDuration } from './duration';
import { reminderActionFromDevice, reminderMethodToDevice } from './values';
export interface ReminderValue {
minutes: number;
/** Reminders.METHOD, normalised: EMAIL (2) or ALERT (1). */
method: number;
}
type Alerts = Record<string, Alert> | null | undefined;
/**
* The alerts that are in force: with `useDefaultAlerts` the calendar's
* defaults for timed or all-day events (and the event's own `alerts` are
* ignored, as the server and the app's scheduler do), else the event's own.
*/
export function effectiveAlerts(
event: { useDefaultAlerts?: boolean | null; alerts?: Alerts; showWithoutTime?: boolean | null },
calendar: CalendarLike | undefined,
): Record<string, Alert> {
if (event.useDefaultAlerts) {
const defaults = event.showWithoutTime ? calendar?.defaultAlertsWithoutTime : calendar?.defaultAlertsWithTime;
return { ...(defaults ?? {}) };
}
return { ...(event.alerts ?? {}) };
}
/** The reminder an alert shows as, or null when a Reminders row cannot hold it. */
export function alertReminder(alert: Alert | null | undefined): ReminderValue | null {
const trigger = alert?.trigger;
if (!trigger || typeof trigger !== 'object') return null;
const type = trigger['@type'];
if (type !== undefined && type !== 'OffsetTrigger') return null;
if (trigger.offset === undefined || trigger.when !== undefined) return null;
if (trigger.relativeTo !== undefined && trigger.relativeTo !== null && trigger.relativeTo !== 'start') return null;
const d = parseDuration(trigger.offset);
if (!d) return null;
const seconds = durationSeconds(d);
if (seconds > 0) return null;
return { minutes: Math.round(-seconds / 60), method: reminderMethodToDevice(alert.action) };
}
export function isRepresentableAlert(alert: Alert | null | undefined): boolean {
return alertReminder(alert) !== null;
}
export function reminderRow(value: ReminderValue): Row {
return { [Reminders.MINUTES]: value.minutes, [Reminders.METHOD]: value.method };
}
/** Reminders rows for the alerts that have one, in the alerts' order. */
export function reminderRows(alerts: Alerts): Row[] {
return Object.values(alerts ?? {})
.map(alertReminder)
.filter((r): r is ReminderValue => r !== null)
.map(reminderRow);
}
/**
* A device row as a reminder, or null for one that means nothing to the
* server (MINUTES = -1 is "the app's default").
*/
export function rowReminder(row: Row): ReminderValue | null {
const minutes = Number(row[Reminders.MINUTES]);
if (!Number.isInteger(minutes) || minutes < 0) return null;
return { minutes, method: reminderMethodToDevice(reminderActionFromDevice(row[Reminders.METHOD])) };
}
const keyOf = (r: ReminderValue) => `${r.minutes}/${r.method}`;
/** Two reminder lists as multisets of (minutes, method). */
export function sameReminders(a: Row[], b: Row[]): boolean {
const count = (rows: Row[]) => {
const m = new Map<string, number>();
for (const r of rows) {
const v = rowReminder(r);
if (v) m.set(keyOf(v), (m.get(keyOf(v)) ?? 0) + 1);
}
return m;
};
const ca = count(a);
const cb = count(b);
if (ca.size !== cb.size) return false;
for (const [k, n] of ca) if (cb.get(k) !== n) return false;
return true;
}
/** A new alert for a device reminder, shaped like the app's reminder picker writes them. */
export function reminderAlert(value: ReminderValue): Alert {
return {
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(value.minutes), relativeTo: 'start' },
action: reminderActionFromDevice(value.method),
};
}
/**
* The alerts after the device's reminders replaced the representable ones:
* existing alerts whose (minutes, method) a reminder still has keep their
* key and value, the others go, unmatched reminders are added under minted
* keys, and alerts a Reminders row can't hold stay as they are.
*/
export function remindersToAlerts(
current: Record<string, Alert>,
reminders: Row[],
mintKey: (taken: Iterable<string>) => string,
): { alerts: Record<string, Alert>; removed: string[]; added: Record<string, Alert> } {
const wanted = reminders.map(rowReminder).filter((r): r is ReminderValue => r !== null);
const pool = new Map<string, number>();
for (const r of wanted) pool.set(keyOf(r), (pool.get(keyOf(r)) ?? 0) + 1);
const alerts: Record<string, Alert> = {};
const removed: string[] = [];
for (const [key, alert] of Object.entries(current)) {
const r = alertReminder(alert);
if (!r) {
alerts[key] = alert;
continue;
}
const left = pool.get(keyOf(r)) ?? 0;
if (left > 0) {
pool.set(keyOf(r), left - 1);
alerts[key] = alert;
} else {
removed.push(key);
}
}
const added: Record<string, Alert> = {};
for (const r of wanted) {
const left = pool.get(keyOf(r)) ?? 0;
if (left <= 0) continue;
pool.set(keyOf(r), left - 1);
const key = mintKey([...Object.keys(current), ...Object.keys(added)]);
added[key] = reminderAlert(r);
alerts[key] = added[key];
}
return { alerts, removed, added };
}
+251
View File
@@ -0,0 +1,251 @@
/**
* Writing event rows: the op group builder and the translation of "this row
* should hold that" into the fewest provider ops (docs/device-sync.md,
* "Merge rules" and "Uploads").
*
* A group is atomic and its asserts come first, so the builder refuses an
* assert after the first write; `refs` are indexes into the group. Rows are
* addressed by id with `expectCount: 1`, exception rows are deleted by id
* only (a sync-adapter delete never cascades to them), and a write that
* would not change a cell is left out, so an echo writes nothing.
*/
import { Attendees, Events, Reminders } from '../android-columns';
import type { EventBaseline, LocalEventRow, OpGroup, PendingCreate, PoisonMarker } from '../planner';
import type { ProviderOp, Row, WriteRow } from '../types';
import type { CalendarEventWire } from '../wire';
import { deepEqual } from '../common/json';
import { ATTENDEE_CELLS, MASTER_CELLS, EXCEPTION_CELLS, REMINDER_CELLS, encodeBaseline, sameCell } from './columns';
import { rowReminder } from './reminders';
export class GroupBuilder {
private readonly asserts: ProviderOp[] = [];
private readonly writes: ProviderOp[] = [];
constructor(readonly ref: string) {}
assert(op: Extract<ProviderOp, { op: 'assert' }>): void {
if (this.writes.length) throw new Error('calendar planner: an assert after a write');
this.asserts.push(op);
}
/** Adds a write and returns its index in the group (for `refs`). */
write(op: ProviderOp): number {
this.writes.push(op);
return this.asserts.length + this.writes.length - 1;
}
get writeCount(): number {
return this.writes.length;
}
build(): OpGroup {
return { ref: this.ref, ops: [...this.asserts, ...this.writes] };
}
}
/** Columns asserted for a row: sync state plus its projection. */
const ASSERT_EXTRA = [Events.DIRTY, Events.DELETED, Events._SYNC_ID] as const;
export function projectionColumns(isException: boolean): readonly string[] {
return isException ? EXCEPTION_CELLS : MASTER_CELLS;
}
/**
* Asserts that a row still holds what was read: DIRTY, DELETED, `_SYNC_ID`
* and its mapped cells, and with `children` its attendees and reminders
* (each row's cells plus their number, so an added one fails too). DIRTY
* alone guards a clean row against app edits (every app write to an event,
* its attendees or reminders sets it); a dirty row needs the whole
* projection.
*/
export function assertRow(group: GroupBuilder, row: LocalEventRow, isException: boolean, children: boolean): void {
const values: Row = {};
// As read: a row a sync adapter inserted without DIRTY holds NULL there, not 0.
values[Events.DIRTY] = row.cells[Events.DIRTY] ?? null;
values[Events.DELETED] = row.cells[Events.DELETED] ?? null;
values[Events._SYNC_ID] = row.syncId;
for (const c of projectionColumns(isException)) values[c] = row.cells[c] ?? null;
for (const c of ASSERT_EXTRA) if (!(c in values)) values[c] = row.cells[c] ?? null;
group.assert({ op: 'assert', table: 'events', id: row.eventId, values, expectCount: 1 });
if (!children) return;
for (const a of row.attendees) {
group.assert({ op: 'assert', table: 'attendees', id: a.id, values: pick(a.cells, ATTENDEE_CELLS), expectCount: 1 });
}
group.assert({ op: 'assert', table: 'attendees', where: `${Attendees.EVENT_ID} = ?`, args: [row.eventId], expectCount: row.attendees.length });
for (const r of row.reminders) {
group.assert({ op: 'assert', table: 'reminders', id: r.id, values: pick(r.cells, REMINDER_CELLS), expectCount: 1 });
}
group.assert({ op: 'assert', table: 'reminders', where: `${Reminders.EVENT_ID} = ?`, args: [row.eventId], expectCount: row.reminders.length });
}
/** Asserts the number of exception rows of a master, so one an app adds meanwhile fails the group. */
export function assertExceptionCount(group: GroupBuilder, masterId: number, count: number): void {
group.assert({ op: 'assert', table: 'events', where: `${Events.ORIGINAL_ID} = ?`, args: [masterId], expectCount: count });
}
function pick(row: Row, columns: readonly string[]): Row {
const out: Row = {};
for (const c of columns) out[c] = row[c] ?? null;
return out;
}
/** What a row should hold after the group: content, sync columns, children. */
export interface RowState {
isException: boolean;
/** Mapped cells (the projection). */
cells: Row;
attendees: Row[];
/** Null: Reminders rows are left as they are. */
reminders: Row[] | null;
baseline: EventBaseline;
syncId: string | null;
uid: string | null;
/** Masters: SYNC_DATA1; undefined leaves it. */
shadow?: CalendarEventWire | null;
/** Exceptions: SYNC_DATA2 (the recurrence id). */
recurrenceKey?: string | null;
/** SYNC_DATA3; undefined leaves it. */
pending?: PendingCreate | null;
/** SYNC_DATA5; undefined leaves it. */
poison?: PoisonMarker | null;
/** Exceptions: ORIGINAL_SYNC_ID. */
originalSyncId?: string | null;
/** Written as DIRTY=0 when set; DIRTY is otherwise left to the provider. */
clearDirty?: boolean;
/** ORGANIZER is written on insert only when the event has participants (the provider fills the owner otherwise). */
hasParticipants: boolean;
}
function syncValues(state: RowState): WriteRow {
const values: WriteRow = {};
values[Events._SYNC_ID] = state.syncId;
values[Events.UID_2445] = state.uid;
if (state.shadow !== undefined) values[Events.SYNC_DATA1] = state.shadow ? JSON.stringify(state.shadow) : null;
if (state.recurrenceKey !== undefined) values[Events.SYNC_DATA2] = state.recurrenceKey;
if (state.pending !== undefined) values[Events.SYNC_DATA3] = state.pending ? JSON.stringify(state.pending) : null;
values[Events.SYNC_DATA4] = encodeBaseline(state.baseline);
if (state.poison !== undefined) values[Events.SYNC_DATA5] = state.poison ? JSON.stringify(state.poison) : null;
if (state.originalSyncId !== undefined) values[Events.ORIGINAL_SYNC_ID] = state.originalSyncId;
return values;
}
/**
* Inserts a row and its attendees and reminders. `originalId` links an
* exception to its master: an id, or the index of the master's insert in
* this group.
*/
export function insertRow(group: GroupBuilder, state: RowState, originalId?: { id: number } | { ref: number }): number {
// DIRTY is NULL after a sync-adapter insert unless written, and later asserts expect 0.
const values: WriteRow = { ...syncValues(state), [Events.HAS_ATTENDEE_DATA]: 1, [Events.DIRTY]: 0 };
for (const [c, v] of Object.entries(state.cells)) {
if (c === Events.ORGANIZER && !state.hasParticipants) continue;
values[c] = v;
}
const refs: Record<string, number> = {};
if (originalId && 'id' in originalId) values[Events.ORIGINAL_ID] = originalId.id;
if (originalId && 'ref' in originalId) refs[Events.ORIGINAL_ID] = originalId.ref;
const index = group.write({ op: 'insert', table: 'events', values, ...(Object.keys(refs).length ? { refs } : {}) });
for (const a of state.attendees) group.write({ op: 'insert', table: 'attendees', values: { ...a }, refs: { [Attendees.EVENT_ID]: index } });
for (const r of state.reminders ?? []) group.write({ op: 'insert', table: 'reminders', values: { ...r }, refs: { [Reminders.EVENT_ID]: index } });
return index;
}
export function deleteRow(group: GroupBuilder, eventId: number): void {
group.write({ op: 'delete', table: 'events', id: eventId, expectCount: 1 });
}
const sameJson = (a: unknown, b: unknown) => deepEqual(a ?? null, b ?? null);
/**
* Updates an existing row to `state`, writing only cells and sync columns
* that differ, then its attendees (matched by address) and reminders
* (matched by minutes and method). Returns the number of write ops.
*/
export function updateRow(group: GroupBuilder, current: LocalEventRow & { shadow?: CalendarEventWire | null }, state: RowState): number {
const before = group.writeCount;
const values: WriteRow = {};
for (const [c, v] of Object.entries(state.cells)) {
const have = current.cells[c] ?? null;
const want = v ?? null;
// Same meaning (`''` and NULL, `P3600S` and `PT1H`) is no reason to write.
if (have === want || sameCell(c, have, want)) continue;
values[c] = want;
}
// Timing columns go together: the provider keeps DTEND and DURATION apart by what is written.
const timing = [Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY, Events.RRULE];
if (timing.some((c) => c in values)) for (const c of timing) if (c in state.cells) values[c] = state.cells[c];
if ((current.syncId ?? null) !== state.syncId) values[Events._SYNC_ID] = state.syncId;
if ((current.cells[Events.UID_2445] ?? null) !== state.uid) values[Events.UID_2445] = state.uid;
if (state.shadow !== undefined && !sameJson(current.shadow, state.shadow)) {
values[Events.SYNC_DATA1] = state.shadow ? JSON.stringify(state.shadow) : null;
}
if (state.recurrenceKey !== undefined && (current.cells[Events.SYNC_DATA2] ?? null) !== state.recurrenceKey) {
values[Events.SYNC_DATA2] = state.recurrenceKey;
}
if (state.pending !== undefined && !sameJson(current.pending, state.pending)) {
values[Events.SYNC_DATA3] = state.pending ? JSON.stringify(state.pending) : null;
}
if (!sameJson(current.baseline, state.baseline)) values[Events.SYNC_DATA4] = encodeBaseline(state.baseline);
if (state.poison !== undefined && !sameJson(current.poison, state.poison)) {
values[Events.SYNC_DATA5] = state.poison ? JSON.stringify(state.poison) : null;
}
if (state.originalSyncId !== undefined && (current.cells[Events.ORIGINAL_SYNC_ID] ?? null) !== state.originalSyncId) {
values[Events.ORIGINAL_SYNC_ID] = state.originalSyncId;
}
if (state.clearDirty && current.dirty) values[Events.DIRTY] = 0;
if (Object.keys(values).length) group.write({ op: 'update', table: 'events', id: current.eventId, values, expectCount: 1 });
writeAttendees(group, current, state.attendees);
if (state.reminders !== null) writeReminders(group, current, state.reminders);
return group.writeCount - before;
}
const emailKey = (row: Row) => String(row[Attendees.ATTENDEE_EMAIL] ?? '').trim().toLowerCase();
function writeAttendees(group: GroupBuilder, current: LocalEventRow, wanted: Row[]): void {
const unmatched = [...current.attendees];
const inserts: Row[] = [];
const updates: Array<{ id: number; values: Row }> = [];
for (const want of wanted) {
const i = unmatched.findIndex((a) => emailKey(a.cells) === emailKey(want));
if (i < 0) {
inserts.push(want);
continue;
}
const [have] = unmatched.splice(i, 1);
const values: Row = {};
for (const c of ATTENDEE_CELLS) {
const v = want[c] ?? null;
const h = have.cells[c] ?? null;
if (h === v) continue;
// The email's case matters: Android finds "me" by comparing it with OWNER_ACCOUNT exactly.
if (c !== Attendees.ATTENDEE_EMAIL && sameCell(c, h, v)) continue;
values[c] = v;
}
if (Object.keys(values).length) updates.push({ id: have.id, values });
}
for (const gone of unmatched) group.write({ op: 'delete', table: 'attendees', id: gone.id, expectCount: 1 });
for (const u of updates) group.write({ op: 'update', table: 'attendees', id: u.id, values: u.values, expectCount: 1 });
for (const add of inserts) group.write({ op: 'insert', table: 'attendees', values: { ...add, [Attendees.EVENT_ID]: current.eventId } });
}
function writeReminders(group: GroupBuilder, current: LocalEventRow, wanted: Row[]): void {
const key = (row: Row) => {
const r = rowReminder(row);
return r ? `${r.minutes}/${r.method}` : `raw:${String(row[Reminders.MINUTES])}/${String(row[Reminders.METHOD])}`;
};
const unmatched = [...current.reminders];
const inserts: Row[] = [];
for (const want of wanted) {
const i = unmatched.findIndex((r) => key(r.cells) === key(want));
if (i < 0) inserts.push(want);
else unmatched.splice(i, 1);
}
for (const gone of unmatched) group.write({ op: 'delete', table: 'reminders', id: gone.id, expectCount: 1 });
for (const add of inserts) group.write({ op: 'insert', table: 'reminders', values: { ...add, [Reminders.EVENT_ID]: current.eventId } });
}
/** Counts the provider writes of a group (asserts are not writes). */
export function writesOf(group: OpGroup): number {
return group.ops.filter((op) => op.op === 'insert' || op.op === 'update' || op.op === 'delete').length;
}
+245
View File
@@ -0,0 +1,245 @@
/**
* JSCalendar `recurrenceRule` ↔ the RRULE column (docs/device-sync.md,
* "Recurrence").
*
* AOSP's EventRecurrence rejects every part it does not know except `X-…`,
* so RSCALE and SKIP are never written (the #805 failure); a rule that needs
* them (a non-Gregorian `rscale`, a `skip` other than `omit`, a leap month
* such as `5L`) is written without them and reported as not representable.
* UNTIL is UTC (`…Z`) for timed events and a DATE for all-day ones, because
* AOSP reads a floating UNTIL as UTC anyway. Rules written by device apps
* are read tolerantly: any case, an `RRULE:` prefix, `X-` parts, UNTIL as a
* DATE, a floating or a UTC date-time (Etar writes UTC date-times even for
* all-day events), COUNT together with UNTIL.
*/
import type { RecurrenceRule } from '../../api/types';
import {
canonicalZone,
dateToUtcMidnight,
formatDate,
localToUtc,
parseLocalDateTime,
utcToLocal,
wallFromMs,
wallMs,
type WallClock,
} from './zoned-time';
export interface RuleZone {
allDay: boolean;
/** The event's zone for timed events (the device zone for floating ones). */
zone: string;
}
const FREQUENCIES = ['yearly', 'monthly', 'weekly', 'daily', 'hourly', 'minutely', 'secondly'];
const WEEKDAYS = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'];
/** Parts EventRecurrence knows, in the order they are written. */
const PART_ORDER = [
'FREQ', 'UNTIL', 'COUNT', 'INTERVAL', 'BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYDAY', 'BYMONTHDAY',
'BYYEARDAY', 'BYWEEKNO', 'BYMONTH', 'BYSETPOS', 'WKST',
] as const;
const KNOWN_PARTS = new Set<string>(PART_ORDER);
/** Why a rule can only be shown approximately; empty when it is representable. */
export function unrepresentableParts(rule: RecurrenceRule | null | undefined): string[] {
if (!rule) return [];
const out: string[] = [];
if (rule.rscale && rule.rscale.toLowerCase() !== 'gregorian') out.push('rscale');
if (rule.skip && rule.skip.toLowerCase() !== 'omit') out.push('skip');
if ((rule.byMonth ?? []).some((m) => /l$/i.test(String(m)))) out.push('byMonth');
if (!FREQUENCIES.includes(String(rule.frequency ?? '').toLowerCase())) out.push('frequency');
return out;
}
export function isRuleRepresentable(rule: RecurrenceRule | null | undefined): boolean {
return unrepresentableParts(rule).length === 0;
}
const ints = (list: unknown): number[] =>
Array.isArray(list) ? list.filter((n): n is number => Number.isInteger(n)) : [];
/**
* The RRULE column for a rule, or null when it cannot be written at all (no
* known frequency). Unrepresentable parts are left out (see the header).
*/
export function ruleToRRule(rule: RecurrenceRule | null | undefined, ctx: RuleZone): string | null {
if (!rule) return null;
const freq = String(rule.frequency ?? '').toLowerCase();
if (!FREQUENCIES.includes(freq)) return null;
const parts: Array<[string, string]> = [['FREQ', freq.toUpperCase()]];
if (Number.isInteger(rule.count) && (rule.count as number) > 0) {
parts.push(['COUNT', String(rule.count)]);
} else if (rule.until) {
const until = formatUntil(rule.until, ctx);
if (until) parts.push(['UNTIL', until]);
}
if (Number.isInteger(rule.interval) && (rule.interval as number) > 1) parts.push(['INTERVAL', String(rule.interval)]);
const list = (name: string, values: Array<string | number>) => {
if (values.length) parts.push([name, values.join(',')]);
};
list('BYSECOND', ints(rule.bySecond));
list('BYMINUTE', ints(rule.byMinute));
list('BYHOUR', ints(rule.byHour));
list(
'BYDAY',
(rule.byDay ?? [])
.filter((d) => d && WEEKDAYS.includes(String(d.day).toLowerCase()))
.map((d) => `${Number.isInteger(d.nthOfPeriod) && d.nthOfPeriod !== 0 ? d.nthOfPeriod : ''}${String(d.day).toUpperCase()}`),
);
list('BYMONTHDAY', ints(rule.byMonthDay));
list('BYYEARDAY', ints(rule.byYearDay));
list('BYWEEKNO', ints(rule.byWeekNo));
list('BYMONTH', (rule.byMonth ?? []).map(String).filter((m) => /^\d{1,2}$/.test(m)).map(Number));
list('BYSETPOS', ints(rule.bySetPosition));
const wkst = String(rule.firstDayOfWeek ?? '').toLowerCase();
if (WEEKDAYS.includes(wkst) && wkst !== 'mo') parts.push(['WKST', wkst.toUpperCase()]);
return parts.map(([k, v]) => `${k}=${v}`).join(';');
}
const compact = (w: WallClock, withTime: boolean) => {
const date = formatDate(w).replace(/-/g, '');
if (!withTime) return date;
const p = (n: number) => String(n).padStart(2, '0');
return `${date}T${p(w.hour)}${p(w.minute)}${p(w.second)}`;
};
/** UNTIL: the date for all-day rules, else the instant of the LocalDateTime in the event's zone, in UTC. */
export function formatUntil(until: string, ctx: RuleZone): string | null {
const wall = parseLocalDateTime(until);
if (!wall) return null;
if (ctx.allDay) return compact(wall, false);
const utc = localToUtc(until, ctx.zone);
return utc === null ? null : `${compact(wallFromMs(utc), true)}Z`;
}
/**
* The instant an UNTIL value stands for, the way AOSP's RecurrenceProcessor
* reads it: a DATE is midnight UTC, a floating date-time is UTC as well.
*/
export function untilInstant(value: string): number | null {
const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/i.exec(value.trim());
if (!m) return null;
return wallMs({
year: Number(m[1]),
month: Number(m[2]),
day: Number(m[3]),
hour: Number(m[4] ?? 0),
minute: Number(m[5] ?? 0),
second: Number(m[6] ?? 0),
});
}
/** UNTIL as JSCalendar's LocalDateTime: the UTC date for all-day rules, the instant in the event's zone otherwise. */
export function parseUntil(value: string, ctx: RuleZone): string | null {
const utc = untilInstant(value);
if (utc === null) return null;
if (ctx.allDay) return `${formatDate(wallFromMs(utc))}T00:00:00`;
return utcToLocal(utc, canonicalZone(ctx.zone) ?? 'UTC');
}
/**
* The parts of an RRULE column as a map (upper-case names and values), from
* its first rule, or null when there is no rule. `X-` parts are dropped and
* so are names EventRecurrence would reject.
*/
export function rruleParts(text: unknown): Map<string, string> | null {
if (typeof text !== 'string') return null;
const first = text.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
if (!first) return null;
const parts = new Map<string, string>();
for (const raw of first.replace(/^RRULE:/i, '').split(';')) {
const eq = raw.indexOf('=');
if (eq <= 0) continue;
const name = raw.slice(0, eq).trim().toUpperCase();
if (name.startsWith('X-') || !KNOWN_PARTS.has(name) || parts.has(name)) continue;
parts.set(name, raw.slice(eq + 1).trim().toUpperCase());
}
return parts.has('FREQ') ? parts : null;
}
/**
* A comparable form of an RRULE column: parts in a fixed order, list values
* sorted, defaults (INTERVAL=1, WKST=MO) dropped. Two texts with the same
* meaning compare equal, so an app that rewrites a rule unchanged (Fossify
* rebuilds it on every save) is not an edit. Empty for no rule.
*/
export function canonicalRRule(text: unknown): string {
const parts = rruleParts(text);
if (!parts) return '';
if (parts.get('INTERVAL') === '1') parts.delete('INTERVAL');
if (parts.get('WKST') === 'MO') parts.delete('WKST');
return PART_ORDER.filter((p) => parts.has(p))
.map((p) => {
const v = parts.get(p)!;
return `${p}=${p.startsWith('BY') ? v.split(',').sort().join(',') : v}`;
})
.join(';');
}
const numberList = (v: string | undefined) =>
v === undefined ? [] : v.split(',').map((n) => Number(n)).filter((n) => Number.isInteger(n));
/**
* A rule from an RRULE column, or null when there is none or its FREQ is
* unknown. A COUNT wins over an UNTIL next to it (RFC 5545 forbids both;
* AOSP only warns).
*/
export function rruleToRule(text: unknown, ctx: RuleZone): RecurrenceRule | null {
const parts = rruleParts(text);
if (!parts) return null;
return partsToRule(parts, ctx);
}
export function partsToRule(parts: Map<string, string>, ctx: RuleZone): RecurrenceRule | null {
const frequency = (parts.get('FREQ') ?? '').toLowerCase();
if (!FREQUENCIES.includes(frequency)) return null;
const rule: RecurrenceRule = { frequency };
const interval = Number(parts.get('INTERVAL'));
if (Number.isInteger(interval) && interval > 1) rule.interval = interval;
const count = Number(parts.get('COUNT'));
if (parts.has('COUNT') && Number.isInteger(count) && count > 0) {
rule.count = count;
} else if (parts.has('UNTIL')) {
const until = parseUntil(parts.get('UNTIL')!, ctx);
if (until) rule.until = until;
}
const byDay = (parts.get('BYDAY') ?? '')
.split(',')
.map((d) => /^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$/.exec(d.trim()))
.filter((m): m is RegExpExecArray => !!m)
.map((m) => (m[1] && Number(m[1]) !== 0 ? { day: m[2].toLowerCase(), nthOfPeriod: Number(m[1]) } : { day: m[2].toLowerCase() }));
if (byDay.length) rule.byDay = byDay;
const lists: Array<[keyof RecurrenceRule, string]> = [
['byMonthDay', 'BYMONTHDAY'],
['byYearDay', 'BYYEARDAY'],
['byWeekNo', 'BYWEEKNO'],
['byHour', 'BYHOUR'],
['byMinute', 'BYMINUTE'],
['bySecond', 'BYSECOND'],
['bySetPosition', 'BYSETPOS'],
];
for (const [key, part] of lists) {
const values = numberList(parts.get(part));
if (values.length) (rule as unknown as Record<string, unknown>)[key] = values;
}
const byMonth = numberList(parts.get('BYMONTH')).filter((m) => m >= 1 && m <= 12).map(String);
if (byMonth.length) rule.byMonth = byMonth;
const wkst = (parts.get('WKST') ?? '').toLowerCase();
if (WEEKDAYS.includes(wkst) && wkst !== 'mo') rule.firstDayOfWeek = wkst;
return rule;
}
/**
* Where a rule stops, for pruning overrides after a "this and following"
* cut: the UNTIL instant, or null when the rule is unbounded or bounded by
* COUNT (the caller then asks CalendarProvider's LAST_DATE).
*/
export function untilUtc(rule: RecurrenceRule | null | undefined, ctx: RuleZone): number | null {
if (!rule?.until || rule.count) return null;
if (ctx.allDay) {
const midnight = dateToUtcMidnight(rule.until);
return midnight === null ? null : midnight + 86_399_000;
}
return localToUtc(rule.until, ctx.zone);
}
+50
View File
@@ -0,0 +1,50 @@
/**
* When a device change asks Stalwart to send iTIP messages
* (docs/device-sync.md, "Scheduling messages"). Stalwart schedules only when
* a `/set` says so, while CalDAV clients get implicit scheduling on every
* change; device sync follows CalDAV for the changes a calendar app would
* notify about:
*
* - the organizer, with others taking part: a change to the title,
* description, timing, rule, overrides, location, status or attendees, a
* create, and a destroy (CANCEL); reminders, colour, availability and
* privacy alone send nothing;
* - an attendee: their own RSVP, and a destroy (a declining REPLY); their
* other edits are stored for them only.
*
* Events in the past need no rule here: Stalwart sends nothing for them.
*/
import type { Participant } from '../../api/types';
import { hasOtherParticipants, userIsOrganizer, type SelfContext } from './attendees';
/** Master units whose change the organizer tells the attendees about. */
export const NOTIFYING_UNITS = ['title', 'description', 'timing', 'rule', 'location', 'status'] as const;
type Scheduled = { organizerCalendarAddress?: string | null; participants?: Record<string, Participant> | null };
export type SchedulingChange =
| {
kind: 'update';
/** A notifying unit, an override or an attendee changed. */
notifying: boolean;
/** The user's own participation status changed. */
rsvp: boolean;
/** Attendees were added (the event may have had no one else before). */
addsAttendees: boolean;
}
| { kind: 'create' }
| { kind: 'destroy' };
/** Whether the write carries `sendSchedulingMessages: true`, for `event` as it is before (or, for a create, as it is sent). */
export function sendsSchedulingMessages(event: Scheduled, change: SchedulingChange, ctx: SelfContext): boolean {
const others = hasOtherParticipants(event.participants, ctx);
switch (change.kind) {
case 'destroy':
return others;
case 'create':
return others && userIsOrganizer(event, ctx);
case 'update':
if (userIsOrganizer(event, ctx)) return (others || change.addsAttendees) && change.notifying;
return change.rsvp;
}
}
+191
View File
@@ -0,0 +1,191 @@
/**
* An event's timing ↔ DTSTART, DTEND, DURATION, EVENT_TIMEZONE and ALL_DAY
* (docs/device-sync.md, "Timing" and "DST edges").
*
* - Timed events are written at the instant of `start` in `timeZone`;
* floating events (no zone) in the device zone, which the row's
* EVENT_TIMEZONE then records.
* - All-day events are UTC midnight of the date with EVENT_TIMEZONE `UTC`,
* the only form CalendarProvider accepts.
* - Single events get DTEND (days nominal in the zone), recurring masters a
* DURATION (`P<n>D` all-day, `P<seconds>S` timed), exceptions DTEND.
*
* Uploads go the other way only for a changed timing: the start is read back
* in the event's zone (floating stays floating, all-day stays a date), and a
* start whose wall time repeats in the zone is flagged, because Stalwart
* drops such a start.
*/
import { Events } from '../android-columns';
import type { Row } from '../types';
import {
allDayDays,
addDuration,
durationBetween,
durationSeconds,
formatAospDuration,
formatJsDuration,
parseDuration,
type Duration,
} from './duration';
import {
DAY_MS,
canonicalZone,
classifyLocal,
dateToUtcMidnight,
fixedOffsetZone,
localToUtc,
offsetAt,
utcMidnightToDate,
utcToLocal,
} from './zoned-time';
export interface TimingSource {
start?: string | null;
duration?: string | null;
timeZone?: string | null;
showWithoutTime?: boolean | null;
}
export interface EventZone {
/** The zone timed values are computed in; `UTC` for all-day events. */
zone: string;
allDay: boolean;
/** No usable `timeZone`: written in the device zone. */
floating: boolean;
}
export function eventZone(event: TimingSource, deviceZone: string): EventZone {
if (event.showWithoutTime) return { zone: 'UTC', allDay: true, floating: false };
const zone = canonicalZone(event.timeZone);
if (zone) return { zone, allDay: false, floating: false };
return { zone: canonicalZone(deviceZone) ?? 'UTC', allDay: false, floating: true };
}
/** The timing cells of an event (or of one instance, `recurring` false). Null when `start` is unreadable. */
export function timingCells(event: TimingSource, recurring: boolean, deviceZone: string): Row | null {
const { zone, allDay } = eventZone(event, deviceZone);
const duration = parseDuration(event.duration ?? 'PT0S') ?? { negative: false, days: 0, seconds: 0 };
if (allDay) {
const dtstart = dateToUtcMidnight(event.start ?? '');
if (dtstart === null) return null;
const days = allDayDays(duration);
return {
[Events.DTSTART]: dtstart,
[Events.DTEND]: recurring ? null : dtstart + days * DAY_MS,
[Events.DURATION]: recurring ? `P${days}D` : null,
[Events.EVENT_TIMEZONE]: 'UTC',
[Events.ALL_DAY]: 1,
};
}
const dtstart = localToUtc(event.start ?? '', zone);
if (dtstart === null) return null;
const nonNegative: Duration = duration.negative ? { negative: false, days: 0, seconds: 0 } : duration;
return {
[Events.DTSTART]: dtstart,
[Events.DTEND]: recurring ? null : addDuration(dtstart, nonNegative, zone),
[Events.DURATION]: recurring ? formatAospDuration(durationSeconds(nonNegative), false) : null,
[Events.EVENT_TIMEZONE]: zone,
[Events.ALL_DAY]: 0,
};
}
/** The timing a row holds, as JSCalendar properties. */
export interface RowTiming {
start: string;
duration: string;
/** The zone the start is in; null for floating and for all-day events without one. */
timeZone: string | null;
showWithoutTime: boolean;
/** Whether `start` names a repeated wall time in `timeZone` (Stalwart drops those). */
ambiguous: boolean;
/** The instant of the start, for fixed-offset fallbacks. */
dtstart: number;
}
export interface RowTimingContext {
/** The event's timing on the server (the shadow), for what an edit left alone. */
server: TimingSource | null;
/** EVENT_TIMEZONE as last written by device sync (the baseline), to tell a zone change from none. */
baselineZone: string | null;
recurring: boolean;
deviceZone: string;
}
const truthy = (v: unknown) => Number(v ?? 0) === 1;
/**
* JSCalendar timing from a row: all-day rows give a date and whole days;
* timed rows give the start in the event's zone and the duration with whole
* days nominal. A floating event stays floating while its row keeps the
* zone it was written in; a zone the user picked (or an all-day event made
* timed) becomes the event's `timeZone`. An EVENT_TIMEZONE Intl doesn't know
* is read as Android reads it, as GMT.
*/
export function rowTiming(cells: Row, ctx: RowTimingContext): RowTiming | null {
const dtstart = Number(cells[Events.DTSTART]);
if (cells[Events.DTSTART] === null || cells[Events.DTSTART] === undefined || !Number.isFinite(dtstart)) return null;
const allDay = truthy(cells[Events.ALL_DAY]);
const lengthOf = (zone: string): Duration => {
if (ctx.recurring || cells[Events.DTEND] === null || cells[Events.DTEND] === undefined) {
const d = parseDuration(cells[Events.DURATION]);
if (!d || d.negative) return { negative: false, days: 0, seconds: 0 };
const s = durationSeconds(d);
return { negative: false, days: Math.floor(s / 86_400), seconds: s % 86_400 };
}
return durationBetween(dtstart, Number(cells[Events.DTEND]), zone);
};
if (allDay) {
const d = lengthOf('UTC');
const days = allDayDays(d);
const keepZone = ctx.server?.showWithoutTime ? (ctx.server.timeZone ?? null) : null;
return {
start: utcMidnightToDate(dtstart),
duration: formatJsDuration(days, 0),
timeZone: keepZone,
showWithoutTime: true,
ambiguous: false,
dtstart,
};
}
const rowZoneRaw = String(cells[Events.EVENT_TIMEZONE] ?? '');
const rowZone = canonicalZone(rowZoneRaw);
const zoneUnchanged = ctx.baselineZone !== null && rowZoneRaw === ctx.baselineZone;
const server = ctx.server;
const serverTimed = !!server && !server.showWithoutTime;
let timeZone: string | null;
let computeIn: string;
if (serverTimed && zoneUnchanged && !canonicalZone(server!.timeZone)) {
// Floating on the server (or a zone Intl doesn't know): shown in the zone it was written in.
timeZone = server!.timeZone ?? null;
computeIn = rowZone ?? 'UTC';
} else if (serverTimed && zoneUnchanged && server!.timeZone) {
timeZone = server!.timeZone;
computeIn = canonicalZone(server!.timeZone)!;
} else if (rowZone) {
timeZone = rowZone;
computeIn = rowZone;
} else {
timeZone = 'Etc/UTC';
computeIn = 'UTC';
}
const start = utcToLocal(dtstart, computeIn);
const d = lengthOf(computeIn);
return {
start,
duration: formatJsDuration(d.days, d.seconds),
timeZone,
showWithoutTime: false,
ambiguous: timeZone !== null && classifyLocal(start, computeIn) === 'overlap',
dtstart,
};
}
/**
* The same instant and wall time in a fixed-offset zone, for a start that is
* a repeated wall time in its own zone (non-recurring events only).
*/
export function fixedOffsetTiming(timing: RowTiming): RowTiming {
const zone = fixedOffsetZone(offsetAt(timing.dtstart, timing.timeZone ? canonicalZone(timing.timeZone) ?? 'UTC' : 'UTC'));
const computeIn = canonicalZone(zone) ?? 'UTC';
return { ...timing, timeZone: zone, start: utcToLocal(timing.dtstart, computeIn), ambiguous: false };
}
+103
View File
@@ -0,0 +1,103 @@
/**
* The units changes are detected, merged and uploaded in (docs/device-sync.md,
* "Units"): groups of related columns, each attendee (by address), the
* reminder set, and for a master each EXDATE entry. Exception rows are
* compared with the same units, recursively.
*/
import { Attendees, Events } from '../android-columns';
import type { EventBaseline, LocalEventRow } from '../planner';
import type { Row } from '../types';
import { addressKey, type SelfContext } from './attendees';
import { MASTER_CELLS, EXCEPTION_CELLS, makeBaseline, sameCell } from './columns';
import { exdateSet } from './exdate';
import { sameReminders } from './reminders';
import type { RowImage } from './image';
export const COLUMN_UNITS: Record<string, readonly string[]> = {
title: [Events.TITLE],
description: [Events.DESCRIPTION],
location: [Events.EVENT_LOCATION],
timing: [Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY],
rule: [Events.RRULE],
status: [Events.STATUS],
availability: [Events.AVAILABILITY],
privacy: [Events.ACCESS_LEVEL],
color: [Events.EVENT_COLOR],
calendar: [Events.CALENDAR_ID],
};
export const MASTER_COLUMN_UNITS = Object.keys(COLUMN_UNITS);
/** An exception has no rule of its own and lives in its master's calendar. */
export const EXCEPTION_COLUMN_UNITS = MASTER_COLUMN_UNITS.filter((u) => u !== 'rule' && u !== 'calendar');
/** Apps that rewrite what they don't model (docs/device-sync.md, "Lossy calendar editors"). */
const LOSSY_EDITORS = ['org.fossify.calendar', 'com.simplemobiletools.calendar.pro', 'com.simplemobiletools.calendar'];
/** Whether MUTATORS names an editor whose attendee types and rule parts can't be trusted. */
export function isLossyEditor(mutators: string | null | undefined): boolean {
if (!mutators) return false;
return mutators.split(',').some((m) => LOSSY_EDITORS.includes(m.trim()));
}
/** One side of a comparison: a row as read, a baseline, or an image. */
export interface Side {
cells: Row;
attendees: Row[];
/** Null: not managed (Bulwark owns reminders). */
reminders: Row[] | null;
}
export function sideOfRow(row: LocalEventRow): Side {
return { cells: row.cells, attendees: row.attendees.map((a) => a.cells), reminders: row.reminders.map((r) => r.cells) };
}
export function sideOfBaseline(b: EventBaseline): Side {
return { cells: b.cells, attendees: b.attendees, reminders: b.reminders };
}
export function sideOfImage(image: RowImage): Side {
return { cells: image.cells, attendees: image.attendees, reminders: image.reminders };
}
export function baselineOfImage(image: RowImage, isException: boolean): EventBaseline {
return makeBaseline(
image.cells,
isException ? EXCEPTION_CELLS : MASTER_CELLS,
image.attendees,
image.reminders ?? [],
image.truncatedDescription,
);
}
export function columnUnitDiffers(unit: string, a: Side, b: Side): boolean {
return (COLUMN_UNITS[unit] ?? []).some((c) => !sameCell(c, a.cells[c], b.cells[c]));
}
/** Attendee rows by address key (the user's aliases are one key). */
export function attendeesByKey(rows: Row[], ctx: SelfContext): Map<string, Row> {
const out = new Map<string, Row>();
for (const row of rows) {
const key = addressKey(row[Attendees.ATTENDEE_EMAIL], ctx);
if (key && !out.has(key)) out.set(key, row);
}
return out;
}
const ATTENDEE_COMPARED = [Attendees.ATTENDEE_NAME, Attendees.ATTENDEE_RELATIONSHIP, Attendees.ATTENDEE_TYPE, Attendees.ATTENDEE_STATUS];
/** What a lossy editor rewrites on every save and so never counts as an edit. */
const LOSSY_IGNORED = new Set<string>([Attendees.ATTENDEE_RELATIONSHIP, Attendees.ATTENDEE_TYPE]);
export function attendeeDiffers(a: Row | undefined, b: Row | undefined, lossy = false): boolean {
if (!a || !b) return !!a !== !!b;
return ATTENDEE_COMPARED.some((c) => !(lossy && LOSSY_IGNORED.has(c)) && !sameCell(c, a[c], b[c]));
}
export function remindersDiffer(a: Side, b: Side): boolean {
if (a.reminders === null || b.reminders === null) return false;
return !sameReminders(a.reminders, b.reminders);
}
/** EXDATE entries of a side, normalised (see `exdateSet`). */
export function exdateEntries(side: Side): Set<string> {
return exdateSet(side.cells[Events.EXDATE], Number(side.cells[Events.ALL_DAY] ?? 0) === 1);
}
+597
View File
@@ -0,0 +1,597 @@
/**
* Device changes → JMAP writes (docs/device-sync.md, "Uploads", "Device-side
* changes" and "Scheduling messages").
*
* A dirty event uploads a patch built per unit from what differs from its
* baseline, against the current shadow: pointers where Stalwart takes them,
* a whole map where it needs one (the first participant, alert or override).
* Exception rows upload only as overrides of their master: a new override
* carries the master's details (Stalwart stores a bare one as a separate
* event otherwise) without the properties Stalwart drops from overrides; an
* existing one is patched one level deep, never deeper, because a deeper
* pointer becomes a partial override that loses the other attendees.
*
* What Android can't represent is never sent: timing and rule of events
* whose rule needs RSCALE, SKIP or leap months; a truncated description.
* Stalwart drops local times it can't resolve, so a start in a repeated hour
* goes out in a fixed-offset zone (single events) or not at all (series:
* `dstAmbiguous`), and an UNTIL in a skipped or repeated hour moves an hour
* later.
*/
import type { RecurrenceRule } from '../../api/types';
import { EventStatus, Events } from '../android-columns';
import type { CalendarContext, LocalEvent, LocalEventRow, LocalException } from '../planner';
import type { Row } from '../types';
import type { CalendarEventWire } from '../wire';
import { clone } from '../common/json';
import { ptr, setInPatch, type PatchObject } from '../common/patch';
import { withNewOverrideDetails } from '../../lib/recurrence-instances';
import { userIsOrganizer } from './attendees';
import { isTruncatedBaseline } from './columns';
import { durationTextSeconds, sameDurationLength } from './duration';
import {
applyAttendeeEdits,
attendeeEdits,
descriptionValue,
locationChanges,
reminderChanges,
titleValue,
type PathChange,
} from './edits';
import { exdateEntryKey } from './exdate';
import {
FORBIDDEN_OVERRIDE_KEYS,
instanceOf,
isExcluded,
keyToInstanceTime,
overridesOf,
withoutForbiddenKeys,
type Override,
} from './exceptions';
import { eventImage, plainInstanceImage } from './image';
import { isRemovedInstance } from './merge';
import { isRuleRepresentable, partsToRule, ruleToRRule, rruleParts, untilUtc, type RuleZone } from './rrule';
import { NOTIFYING_UNITS, sendsSchedulingMessages } from './scheduling';
import { fixedOffsetTiming, rowTiming, type RowTiming } from './timing';
import {
EXCEPTION_COLUMN_UNITS,
MASTER_COLUMN_UNITS,
baselineOfImage,
columnUnitDiffers,
exdateEntries,
isLossyEditor,
remindersDiffer,
sideOfBaseline,
sideOfRow,
type Side,
} from './units';
import { argbToCss, availabilityFromDevice, privacyFromDevice, statusFromDevice } from './values';
import {
canonicalZone,
classifyLocal,
formatLocalDateTime,
parseLocalDateTime,
resolveWallClock,
sameLocalDateTime,
utcToLocal,
wallFromMs,
wallMs,
} from './zoned-time';
/** Why an item is not uploaded now. */
export const SKIP = {
dstAmbiguous: 'dstAmbiguous',
ruleNotRepresentable: 'ruleNotRepresentable',
instanceOnly: 'instanceOnly',
notOurCalendar: 'notOurCalendar',
} as const;
export class SkipUpload extends Error {
constructor(readonly reason: string) {
super(reason);
}
}
const num = (v: unknown) => (v === null || v === undefined || v === '' ? null : Number.isFinite(Number(v)) ? Number(v) : null);
/** The zone and all-day flag the series' LocalDateTimes (start, keys, UNTIL) are in, as written to the rows. */
export function seriesZone(local: LocalEventRow, shadow: CalendarEventWire | null, deviceZone: string): RuleZone {
const allDay = shadow ? !!shadow.showWithoutTime : Number(local.cells[Events.ALL_DAY] ?? 0) === 1;
if (allDay) return { allDay: true, zone: 'UTC' };
const zone =
canonicalZone(shadow?.timeZone) ??
canonicalZone(local.baseline?.cells[Events.EVENT_TIMEZONE]) ??
canonicalZone(local.cells[Events.EVENT_TIMEZONE]) ??
canonicalZone(deviceZone) ??
'UTC';
return { allDay: false, zone };
}
/** A wall time Stalwart would drop (skipped or repeated) moved an hour later. */
function safeUntil(until: string, zone: RuleZone): string {
if (zone.allDay) return until;
const kind = classifyLocal(until, zone.zone);
if (kind !== 'gap' && kind !== 'overlap') return until;
const wall = parseLocalDateTime(until)!;
return formatLocalDateTime(wallFromMs(wallMs(wall) + 3_600_000));
}
/** Parts Fossify Calendar's rule editor models; others it drops, and they stay. */
const LOSSY_RULE_PARTS = ['FREQ', 'INTERVAL', 'COUNT', 'UNTIL', 'BYDAY', 'BYMONTHDAY', 'BYMONTH'];
/**
* The rule a device RRULE stands for. A lossy editor's rule is applied part
* by part onto the server's rule: parts it models and changed are taken,
* parts it dropped stay (docs/device-sync.md, "Lossy calendar editors").
*/
export function deviceRule(
rrule: unknown,
baselineRRule: unknown,
server: RecurrenceRule | null | undefined,
zone: RuleZone,
lossy: boolean,
): RecurrenceRule | null {
const parts = rruleParts(rrule);
if (!parts) return null;
let merged = parts;
if (lossy && server) {
const before = rruleParts(baselineRRule) ?? new Map<string, string>();
const serverParts = rruleParts(ruleText(server, zone)) ?? new Map<string, string>();
merged = new Map(serverParts);
for (const part of LOSSY_RULE_PARTS) {
const now = parts.get(part);
if (now !== undefined && now !== before.get(part)) {
merged.set(part, now);
if (part === 'COUNT') merged.delete('UNTIL');
if (part === 'UNTIL') merged.delete('COUNT');
}
}
}
const rule = partsToRule(merged, zone);
if (rule?.until) rule.until = safeUntil(rule.until, zone);
return rule;
}
/** The server's rule in RRULE parts, so both sides of a lossy merge compare in one dialect. */
function ruleText(rule: RecurrenceRule, zone: RuleZone): string {
return ruleToRRule(rule, zone) ?? '';
}
// ─── Patches for a dirty event ──────────────────────────
export interface UploadComputation {
patch: PatchObject;
/** Master units in the patch. */
masterUnits: Set<string>;
/** Exception rows (by id) whose changes are in the patch. */
exceptionUnits: Map<number, Set<string>>;
/** Exception rows whose instance is excluded by the patch, or already was. */
removedExceptions: Set<number>;
sendSchedulingMessages: boolean;
/** Units changed on the device that can't be uploaded (reported when nothing else is). */
dropped: string[];
}
interface OverrideWork {
/** Whole override values (or null) by recurrence id. */
whole: Map<string, Override | null>;
/** One-level properties of existing overrides by recurrence id. */
props: Map<string, Record<string, unknown>>;
}
/** Sets a raw path in a patch: escaped once, never overlapping a pointer already there. */
export function put(patch: PatchObject, path: string[], value: unknown): void {
setInPatch(patch, ptr(...path), value);
}
function putAll(patch: PatchObject, root: string[], changes: PathChange[]): void {
for (const c of changes) put(patch, [...root, ...c.path], c.value);
}
/**
* The patch for a dirty event with identity (see the header). Throws
* `SkipUpload` for an item that must not be uploaded now.
*/
export function computeUpdate(local: LocalEvent, ctx: CalendarContext): UploadComputation {
const shadow = local.shadow!;
const calendar = ctx.calendarIdOfRow(local.calendarRowId);
const calendarId = calendar?.calendarId ?? Object.keys(shadow.calendarIds ?? {})[0] ?? '';
const image = eventImage(shadow, local.calendarRowId, calendarId, ctx);
const baseline = local.baseline ?? (image ? baselineOfImage(image.master, false) : null);
const zone = seriesZone(local, shadow, ctx.deviceZone);
const lossy = isLossyEditor(local.mutators);
const patch: PatchObject = {};
const masterUnits = new Set<string>();
const exceptionUnits = new Map<number, Set<string>>();
const removedExceptions = new Set<number>();
const dropped: string[] = [];
const overrides: OverrideWork = { whole: new Map(), props: new Map() };
const representable = isRuleRepresentable(shadow.recurrenceRule) && !shadow.recurrenceId;
const serverOverrides = overridesOf(shadow);
let rsvp = false;
let attendeeOthers = false;
let overridesChanged = false;
let ruleRemoved = false;
let rekey: number | null = null;
const cur = sideOfRow(local);
const bl: Side | null = baseline ? sideOfBaseline(baseline) : null;
const changed = (unit: string) => {
if (!bl) return false;
const forced = local.split === 'source' && unit === 'rule';
return (local.dirty || forced) && columnUnitDiffers(unit, cur, bl);
};
for (const unit of MASTER_COLUMN_UNITS) {
if (!changed(unit)) continue;
const cells = local.cells;
switch (unit) {
case 'title':
put(patch, ['title'], titleValue(cells));
break;
case 'description':
if (isTruncatedBaseline(baseline!.cells[Events.DESCRIPTION])) {
dropped.push(unit);
continue;
}
put(patch, ['description'], descriptionValue(cells));
if (String(shadow.descriptionContentType ?? '').toLowerCase().startsWith('text/html')) {
put(patch, ['descriptionContentType'], 'text/plain');
}
break;
case 'location':
putAll(patch, [], locationChanges(shadow, cells[Events.EVENT_LOCATION], ctx.mintKey).changes);
break;
case 'status':
put(patch, ['status'], statusFromDevice(cells[Events.STATUS]));
break;
case 'availability':
put(patch, ['freeBusyStatus'], availabilityFromDevice(cells[Events.AVAILABILITY]));
break;
case 'privacy':
put(patch, ['privacy'], privacyFromDevice(cells[Events.ACCESS_LEVEL]));
break;
case 'color':
put(patch, ['color'], argbToCss(cells[Events.EVENT_COLOR]));
break;
case 'calendar': {
const to = ctx.calendarIdOfRow(Number(cells[Events.CALENDAR_ID]));
const from = ctx.calendarIdOfRow(Number(baseline!.cells[Events.CALENDAR_ID]));
if (!to || !from || to.jmapAccountId !== ctx.jmapAccountId || from.jmapAccountId !== ctx.jmapAccountId) {
dropped.push(unit);
continue;
}
put(patch, ['calendarIds', from.calendarId], null);
put(patch, ['calendarIds', to.calendarId], true);
break;
}
case 'timing': {
if (!representable) {
dropped.push(unit);
continue;
}
const recurring = !!cells[Events.RRULE];
let t = rowTiming(cells, {
server: shadow,
baselineZone: baseline!.cells[Events.EVENT_TIMEZONE] === null ? null : String(baseline!.cells[Events.EVENT_TIMEZONE] ?? ''),
recurring,
deviceZone: ctx.deviceZone,
});
if (!t) {
dropped.push(unit);
continue;
}
if (t.ambiguous) {
if (recurring) throw new SkipUpload(SKIP.dstAmbiguous);
t = fixedOffsetTiming(t);
}
applyTiming(patch, [], t, shadow);
if (recurring && shadow.recurrenceRule && !sameLocalDateTime(t.start, shadow.start)) {
rekey = wallMs(parseLocalDateTime(t.start)!) - wallMs(parseLocalDateTime(shadow.start)!);
}
break;
}
case 'rule': {
if (!representable) {
dropped.push(unit);
continue;
}
const ruleZone: RuleZone = { allDay: Number(cells[Events.ALL_DAY] ?? 0) === 1, zone: zone.zone };
const rule = deviceRule(cells[Events.RRULE], baseline!.cells[Events.RRULE], shadow.recurrenceRule, ruleZone, lossy);
if (!rule) {
put(patch, ['recurrenceRule'], null);
if (Object.keys(serverOverrides).length) put(patch, ['recurrenceOverrides'], null);
ruleRemoved = true;
break;
}
put(patch, ['recurrenceRule'], rule);
// "This and following": overrides after the new end go.
const end = seriesEnd(rule, local, ruleZone);
if (end !== null) {
for (const key of Object.keys(serverOverrides)) {
const at = keyToInstanceTime(key, ruleZone.allDay, ruleZone.zone);
if (at !== null && at > end) {
overrides.whole.set(key, null);
overridesChanged = true;
}
}
}
break;
}
}
masterUnits.add(unit);
}
// EXDATE entries added or removed on the device.
if (bl && local.dirty && !ruleRemoved) {
const now = exdateEntries(cur);
const before = exdateEntries(bl);
for (const entry of new Set([...now, ...before])) {
if (now.has(entry) === before.has(entry)) continue;
const key = exdateEntryKey(entry, zone);
if (!key) continue;
if (!zone.allDay && classifyLocal(key, zone.zone) === 'overlap') throw new SkipUpload(SKIP.dstAmbiguous);
if (now.has(entry) && !isExcluded(serverOverrides[key])) overrides.whole.set(key, { excluded: true });
else if (!now.has(entry) && isExcluded(serverOverrides[key])) overrides.whole.set(key, null);
else continue;
overridesChanged = true;
masterUnits.add(`exdate:${entry}`);
}
}
// Attendees.
if (bl && local.dirty) {
const edits = attendeeEdits(cur.attendees, bl.attendees, ctx, lossy);
if (edits.length) {
const result = applyAttendeeEdits(shadow.participants, edits, ctx, ctx.mintKey, lossy);
if (result.changes.length) {
if (!Object.keys(shadow.participants ?? {}).length) put(patch, ['participants'], result.participants);
else putAll(patch, ['participants'], result.changes);
rsvp = rsvp || result.rsvp;
attendeeOthers = attendeeOthers || result.others;
for (const e of edits) masterUnits.add(`attendee:${e.key}`);
}
}
}
// Reminders, only when the calendar app owns them.
if (bl && local.dirty && ctx.reminderOwner === 'device' && remindersDiffer(cur, bl)) {
putAll(patch, [], reminderChanges(shadow, ctx.calendar(calendarId), cur.reminders!, ctx.mintKey).changes);
masterUnits.add('reminders');
}
// Exception rows.
if (!ruleRemoved && shadow.recurrenceRule) {
for (const x of local.exceptions) {
const units = exceptionChanges(x, local, shadow, calendarId, zone, overrides, removedExceptions, ctx, lossy);
if (units.units.size) {
exceptionUnits.set(x.eventId, units.units);
overridesChanged = true;
rsvp = rsvp || units.rsvp;
}
}
}
if (!ruleRemoved) materializeOverrides(patch, shadow, overrides, rekey, zone);
const notifying = NOTIFYING_UNITS.some((u) => masterUnits.has(u)) || overridesChanged || attendeeOthers;
const sendSchedulingMessages = sendsSchedulingMessages(shadow, { kind: 'update', notifying, rsvp, addsAttendees: attendeeOthers }, ctx);
return { patch, masterUnits, exceptionUnits, removedExceptions, sendSchedulingMessages, dropped };
}
/** Timing properties that differ from the server's. */
function applyTiming(patch: PatchObject, root: string[], t: RowTiming, server: { start?: string; duration?: string; timeZone?: string | null; showWithoutTime?: boolean | null }): void {
if (!sameLocalDateTime(t.start, server.start)) put(patch, [...root, 'start'], t.start);
if (!sameDurationLength(t.duration, server.duration ?? 'PT0S')) put(patch, [...root, 'duration'], t.duration);
if (root.length === 0 && (t.timeZone ?? null) !== (server.timeZone ?? null)) put(patch, ['timeZone'], t.timeZone);
if (root.length === 0 && !!t.showWithoutTime !== !!server.showWithoutTime) put(patch, ['showWithoutTime'], t.showWithoutTime);
}
/**
* Where the capped series ends: the UNTIL instant, or for a COUNT the start
* of the last instance as CalendarProvider computed it (LAST_DATE minus the
* duration). Null when unknown or unbounded.
*/
function seriesEnd(rule: RecurrenceRule, local: LocalEventRow, zone: RuleZone): number | null {
if (rule.until && !rule.count) return untilUtc(rule, zone);
if (!rule.count) return null;
const lastDate = num(local.cells[Events.LAST_DATE]);
const seconds = durationTextSeconds(local.cells[Events.DURATION]);
if (lastDate === null || seconds === null) return null;
return lastDate - seconds * 1000;
}
function exceptionChanges(
x: LocalException,
master: LocalEvent,
shadow: CalendarEventWire,
calendarId: string,
zone: RuleZone,
work: OverrideWork,
removed: Set<number>,
ctx: CalendarContext,
lossy: boolean,
): { units: Set<string>; rsvp: boolean } {
const units = new Set<string>();
const key = x.recurrenceId;
if (!key) return { units, rsvp: false };
const ours = typeof x.cells[Events.SYNC_DATA2] === 'string' && !!x.cells[Events.SYNC_DATA2];
if (!ours && !zone.allDay && classifyLocal(key, zone.zone) === 'overlap') throw new SkipUpload(SKIP.dstAmbiguous);
const server = overridesOf(shadow)[key];
const cancelled = Number(x.cells[Events.STATUS]) === EventStatus.CANCELED;
if (isRemovedInstance(x) || (cancelled && isExcluded(server))) {
removed.add(x.eventId);
if (!isExcluded(server)) {
work.whole.set(key, { excluded: true });
units.add('removed');
}
return { units, rsvp: false };
}
if (!x.dirty) return { units, rsvp: false };
const plain = plainInstanceImage(shadow, key, master.calendarRowId, calendarId, ctx);
if (!plain) return { units, rsvp: false };
const existing = !!server && !isExcluded(server);
const instance = instanceOf(shadow, key, existing ? server : null);
// An existing override changes against what device sync last wrote; a new one against the plain instance.
const reference: Side = existing && x.baseline ? sideOfBaseline(x.baseline) : { cells: plain.cells, attendees: plain.attendees, reminders: plain.reminders };
const cur = sideOfRow(x);
const values: Record<string, unknown> = {};
let rsvp = false;
const organizer = userIsOrganizer(shadow, ctx);
for (const unit of EXCEPTION_COLUMN_UNITS) {
if (unit === 'privacy') continue; // Stalwart drops privacy from overrides.
// Etar answers one instance with STATUS_CONFIRMED on it; an attendee may not change an event's status.
if (unit === 'status' && !organizer) continue;
if (unit !== 'timing' && !columnUnitDiffers(unit, cur, reference)) continue;
// A new exception row holds what its app copied: an empty value there is one it did not model, not a deletion.
if (!existing && INHERITED_WHEN_EMPTY.has(unit) && isEmptyUnit(unit, x.cells)) continue;
const cells = x.cells;
switch (unit) {
case 'title':
values.title = titleValue(cells);
break;
case 'description':
if (x.baseline && isTruncatedBaseline(x.baseline.cells[Events.DESCRIPTION])) continue;
values.description = descriptionValue(cells);
break;
case 'location':
values.locations = locationChanges(instance, cells[Events.EVENT_LOCATION], ctx.mintKey).locations;
break;
case 'status':
values.status = statusFromDevice(cells[Events.STATUS]);
break;
case 'availability':
values.freeBusyStatus = availabilityFromDevice(cells[Events.AVAILABILITY]);
break;
case 'color':
values.color = argbToCss(cells[Events.EVENT_COLOR]);
break;
case 'timing': {
const moved = columnUnitDiffers('timing', cur, reference);
if (existing && !moved) continue;
let t = rowTiming(cells, {
server: { ...instance, showWithoutTime: shadow.showWithoutTime, timeZone: instance.timeZone ?? shadow.timeZone },
baselineZone: String(reference.cells[Events.EVENT_TIMEZONE] ?? ''),
recurring: false,
deviceZone: ctx.deviceZone,
});
if (!t) continue;
if (t.ambiguous) throw new SkipUpload(SKIP.dstAmbiguous);
if (t.showWithoutTime !== !!shadow.showWithoutTime) t = { ...t, showWithoutTime: !!shadow.showWithoutTime };
if (existing) {
if (!sameLocalDateTime(t.start, instance.start)) values.start = t.start;
if (!sameDurationLength(t.duration, instance.duration ?? 'PT0S')) values.duration = t.duration;
if (!('start' in values) && !('duration' in values)) continue;
} else {
// A new override pins its start and length, as the app's own occurrence edits do.
values.start = t.start;
values.duration = t.duration;
if (!moved) continue;
}
break;
}
}
units.add(unit);
}
// A new exception row without attendees or reminders did not copy them: the series' apply.
const edits = existing || cur.attendees.length ? attendeeEdits(cur.attendees, reference.attendees, ctx, lossy) : [];
if (edits.length) {
const result = applyAttendeeEdits(instance.participants, edits, ctx, ctx.mintKey, lossy);
if (result.changes.length) {
values.participants = result.participants;
rsvp = result.rsvp;
units.add('attendees');
}
}
if (ctx.reminderOwner === 'device' && (existing || cur.reminders!.length) && remindersDiffer(cur, reference)) {
values.alerts = reminderChanges(instance, ctx.calendar(calendarId), cur.reminders!, ctx.mintKey).alerts;
units.add('reminders');
}
if (existing) {
if (units.size) work.props.set(key, { ...(work.props.get(key) ?? {}), ...values });
return { units, rsvp };
}
// A new override: nothing but its pinned time is still an override (an app saving the instance unchanged).
if (!units.size) units.add('new');
work.whole.set(key, newOverride(shadow, key, values));
return { units, rsvp };
}
/** Units a new exception row inherits from its series when it leaves them empty. */
const INHERITED_WHEN_EMPTY = new Set(['title', 'description', 'location', 'color']);
function isEmptyUnit(unit: string, cells: Row): boolean {
const column = { title: Events.TITLE, description: Events.DESCRIPTION, location: Events.EVENT_LOCATION, color: Events.EVENT_COLOR }[unit];
const v = column ? cells[column] : null;
return v === null || v === undefined || v === '';
}
/**
* A new override: the changes plus the details Stalwart needs on it (title,
* locations, participants, sequence, …; `withNewOverrideDetails`), minus what
* Stalwart drops from overrides. The master's own alerts are not copied
* while it uses the calendar's defaults: they are not in force.
*/
export function newOverride(master: CalendarEventWire, key: string, values: Record<string, unknown>): Override {
const occurrence = {
...(master as unknown as Record<string, unknown>),
recurrenceId: key,
recurrenceOverrides: master.recurrenceOverrides ?? {},
start: undefined,
baseEventId: undefined,
};
const withDetails = withNewOverrideDetails(occurrence as never, values as never) as Record<string, unknown>;
if (master.useDefaultAlerts && !('alerts' in values)) delete withDetails.alerts;
const out = withoutForbiddenKeys(withDetails);
for (const name of FORBIDDEN_OVERRIDE_KEYS) delete out[name];
return out;
}
function materializeOverrides(patch: PatchObject, shadow: CalendarEventWire, work: OverrideWork, rekey: number | null, zone: RuleZone): void {
const server = overridesOf(shadow);
if (rekey !== null && Object.keys(server).length) {
// "All events" moved the series: every recurrence id moves by the same wall-clock offset.
const map: Record<string, Override> = clone(server);
for (const [key, value] of work.whole) {
if (value === null) delete map[key];
else map[key] = value;
}
for (const [key, props] of work.props) if (map[key]) map[key] = { ...map[key], ...props };
const shifted: Record<string, Override> = {};
for (const [key, value] of Object.entries(map)) {
const moved = shiftLocal(key, rekey, zone);
const next = clone(value);
if (!isExcluded(next) && typeof next.start === 'string' && sameLocalDateTime(next.start, key)) next.start = shiftLocal(next.start, rekey, zone);
shifted[moved] = next;
}
put(patch, ['recurrenceOverrides'], Object.keys(shifted).length ? shifted : null);
return;
}
if (!Object.keys(server).length) {
// The first override: a pointer below a missing map fails, so the whole map goes.
const whole: Record<string, Override> = {};
for (const [key, value] of work.whole) if (value) whole[key] = value;
if (Object.keys(whole).length) put(patch, ['recurrenceOverrides'], whole);
return;
}
for (const [key, value] of work.whole) put(patch, ['recurrenceOverrides', key], value);
for (const [key, props] of work.props) for (const [name, value] of Object.entries(props)) put(patch, ['recurrenceOverrides', key, name], value);
}
/** A LocalDateTime moved by wall-clock milliseconds; a result in a skipped hour moves forward like the instance does. */
function shiftLocal(value: string, deltaMs: number, zone: RuleZone): string {
const wall = parseLocalDateTime(value);
if (!wall) return value;
const moved = wallFromMs(wallMs(wall) + deltaMs);
const text = formatLocalDateTime(moved);
if (zone.allDay) return text;
const kind = classifyLocal(text, zone.zone);
if (kind === 'overlap') throw new SkipUpload(SKIP.dstAmbiguous);
if (kind === 'gap') return utcToLocal(resolveWallClock(moved, canonicalZone(zone.zone) ?? 'UTC').utc, zone.zone);
return text;
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Value tables between JSCalendar and CalendarContract (docs/device-sync.md,
* "Other properties"): status, availability, privacy, colours and the
* attendee enums. Pure lookups, both directions.
*/
import {
AttendeeRelationship,
AttendeeStatus,
AttendeeType,
EventAccess,
EventAvailability,
EventStatus,
ReminderMethod,
} from '../android-columns';
// ─── Event status, availability, privacy ─────────────────
/** `status` → STATUS; absent (JSCalendar's default is confirmed) → CONFIRMED. Never null: the provider throws on it. */
export function statusToDevice(status: unknown): number {
switch (String(status ?? '').toLowerCase()) {
case 'tentative':
return EventStatus.TENTATIVE;
case 'cancelled':
return EventStatus.CANCELED;
default:
return EventStatus.CONFIRMED;
}
}
export function statusFromDevice(value: unknown): 'confirmed' | 'tentative' | 'cancelled' {
const n = Number(value);
if (n === EventStatus.TENTATIVE) return 'tentative';
if (n === EventStatus.CANCELED) return 'cancelled';
return 'confirmed';
}
/** `freeBusyStatus` → AVAILABILITY; JSCalendar's default is busy. */
export function availabilityToDevice(freeBusy: unknown): number {
return String(freeBusy ?? '').toLowerCase() === 'free' ? EventAvailability.FREE : EventAvailability.BUSY;
}
/** AVAILABILITY → `freeBusyStatus`; TENTATIVE has no JSCalendar value and uploads as busy. */
export function availabilityFromDevice(value: unknown): 'free' | 'busy' {
return Number(value) === EventAvailability.FREE ? 'free' : 'busy';
}
/** `privacy` → ACCESS_LEVEL: absent DEFAULT, public PUBLIC, private PRIVATE, secret CONFIDENTIAL. */
export function privacyToDevice(privacy: unknown): number {
switch (String(privacy ?? '').toLowerCase()) {
case 'public':
return EventAccess.PUBLIC;
case 'private':
return EventAccess.PRIVATE;
case 'secret':
return EventAccess.CONFIDENTIAL;
default:
return EventAccess.DEFAULT;
}
}
/** ACCESS_LEVEL → `privacy`; DEFAULT removes the property. */
export function privacyFromDevice(value: unknown): 'public' | 'private' | 'secret' | null {
switch (Number(value)) {
case EventAccess.PUBLIC:
return 'public';
case EventAccess.PRIVATE:
return 'private';
case EventAccess.CONFIDENTIAL:
return 'secret';
default:
return null;
}
}
// ─── Attendees ──────────────────────────────────────────
/** participationStatus → ATTENDEE_STATUS: accepted 1, declined 2, needs-action (the default) 3, tentative 4, else NONE. */
export function participationToDevice(status: unknown): number {
switch (String(status ?? 'needs-action').toLowerCase()) {
case 'accepted':
return AttendeeStatus.ACCEPTED;
case 'declined':
return AttendeeStatus.DECLINED;
case 'needs-action':
return AttendeeStatus.INVITED;
case 'tentative':
return AttendeeStatus.TENTATIVE;
default:
return AttendeeStatus.NONE;
}
}
/** ATTENDEE_STATUS → participationStatus; NONE has no JSCalendar value. */
export function participationFromDevice(value: unknown): string | null {
switch (Number(value)) {
case AttendeeStatus.ACCEPTED:
return 'accepted';
case AttendeeStatus.DECLINED:
return 'declined';
case AttendeeStatus.INVITED:
return 'needs-action';
case AttendeeStatus.TENTATIVE:
return 'tentative';
default:
return null;
}
}
/** ATTENDEE_TYPE: the `optional` role → OPTIONAL, kind resource/location → RESOURCE, else REQUIRED. */
export function attendeeTypeToDevice(participant: { roles?: Record<string, boolean> | null; kind?: string | null }): number {
const kind = String(participant.kind ?? '').toLowerCase();
if (kind === 'resource' || kind === 'location') return AttendeeType.RESOURCE;
if (participant.roles?.optional) return AttendeeType.OPTIONAL;
return AttendeeType.REQUIRED;
}
export function relationshipToDevice(isOrganizer: boolean): number {
return isOrganizer ? AttendeeRelationship.ORGANIZER : AttendeeRelationship.ATTENDEE;
}
// ─── Reminders ──────────────────────────────────────────
/** Reminders.METHOD for an alert action: email → EMAIL, anything else → ALERT. */
export function reminderMethodToDevice(action: unknown): number {
return String(action ?? '').toLowerCase() === 'email' ? ReminderMethod.EMAIL : ReminderMethod.ALERT;
}
/** The alert action of a METHOD: EMAIL → email; DEFAULT, ALERT, ALARM and SMS all fire a notification. */
export function reminderActionFromDevice(method: unknown): 'email' | 'display' {
return Number(method) === ReminderMethod.EMAIL ? 'email' : 'display';
}
// ─── Colours ────────────────────────────────────────────
/** CSS named colours (CSS Color Module Level 4), as `name:rrggbb`. */
const NAMED_COLORS = Object.fromEntries(
(
'aliceblue:f0f8ff antiquewhite:faebd7 aqua:00ffff aquamarine:7fffd4 azure:f0ffff beige:f5f5dc bisque:ffe4c4 ' +
'black:000000 blanchedalmond:ffebcd blue:0000ff blueviolet:8a2be2 brown:a52a2a burlywood:deb887 ' +
'cadetblue:5f9ea0 chartreuse:7fff00 chocolate:d2691e coral:ff7f50 cornflowerblue:6495ed cornsilk:fff8dc ' +
'crimson:dc143c cyan:00ffff darkblue:00008b darkcyan:008b8b darkgoldenrod:b8860b darkgray:a9a9a9 ' +
'darkgreen:006400 darkgrey:a9a9a9 darkkhaki:bdb76b darkmagenta:8b008b darkolivegreen:556b2f ' +
'darkorange:ff8c00 darkorchid:9932cc darkred:8b0000 darksalmon:e9967a darkseagreen:8fbc8f ' +
'darkslateblue:483d8b darkslategray:2f4f4f darkslategrey:2f4f4f darkturquoise:00ced1 darkviolet:9400d3 ' +
'deeppink:ff1493 deepskyblue:00bfff dimgray:696969 dimgrey:696969 dodgerblue:1e90ff firebrick:b22222 ' +
'floralwhite:fffaf0 forestgreen:228b22 fuchsia:ff00ff gainsboro:dcdcdc ghostwhite:f8f8ff gold:ffd700 ' +
'goldenrod:daa520 gray:808080 green:008000 greenyellow:adff2f grey:808080 honeydew:f0fff0 hotpink:ff69b4 ' +
'indianred:cd5c5c indigo:4b0082 ivory:fffff0 khaki:f0e68c lavender:e6e6fa lavenderblush:fff0f5 ' +
'lawngreen:7cfc00 lemonchiffon:fffacd lightblue:add8e6 lightcoral:f08080 lightcyan:e0ffff ' +
'lightgoldenrodyellow:fafad2 lightgray:d3d3d3 lightgreen:90ee90 lightgrey:d3d3d3 lightpink:ffb6c1 ' +
'lightsalmon:ffa07a lightseagreen:20b2aa lightskyblue:87cefa lightslategray:778899 lightslategrey:778899 ' +
'lightsteelblue:b0c4de lightyellow:ffffe0 lime:00ff00 limegreen:32cd32 linen:faf0e6 magenta:ff00ff ' +
'maroon:800000 mediumaquamarine:66cdaa mediumblue:0000cd mediumorchid:ba55d3 mediumpurple:9370db ' +
'mediumseagreen:3cb371 mediumslateblue:7b68ee mediumspringgreen:00fa9a mediumturquoise:48d1cc ' +
'mediumvioletred:c71585 midnightblue:191970 mintcream:f5fffa mistyrose:ffe4e1 moccasin:ffe4b5 ' +
'navajowhite:ffdead navy:000080 oldlace:fdf5e6 olive:808000 olivedrab:6b8e23 orange:ffa500 ' +
'orangered:ff4500 orchid:da70d6 palegoldenrod:eee8aa palegreen:98fb98 paleturquoise:afeeee ' +
'palevioletred:db7093 papayawhip:ffefd5 peachpuff:ffdab9 peru:cd853f pink:ffc0cb plum:dda0dd ' +
'powderblue:b0e0e6 purple:800080 rebeccapurple:663399 red:ff0000 rosybrown:bc8f8f royalblue:4169e1 ' +
'saddlebrown:8b4513 salmon:fa8072 sandybrown:f4a460 seagreen:2e8b57 seashell:fff5ee sienna:a0522d ' +
'silver:c0c0c0 skyblue:87ceeb slateblue:6a5acd slategray:708090 slategrey:708090 snow:fffafa ' +
'springgreen:00ff7f steelblue:4682b4 tan:d2b48c teal:008080 thistle:d8bfd8 tomato:ff6347 ' +
'turquoise:40e0d0 violet:ee82ee wheat:f5deb3 white:ffffff whitesmoke:f5f5f5 yellow:ffff00 ' +
'yellowgreen:9acd32'
)
.split(' ')
.map((pair) => pair.split(':') as [string, string]),
);
/**
* A CSS colour (a name, `#rgb` or `#rrggbb`) as the signed 32-bit ARGB int
* the provider stores, fully opaque; null for anything else.
*/
export function cssColorToArgb(color: unknown): number | null {
if (typeof color !== 'string') return null;
const c = color.trim().toLowerCase();
let hex = NAMED_COLORS[c] ?? null;
if (!hex) {
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(c);
if (!m) return null;
hex = m[1].length === 3 ? m[1].split('').map((ch) => ch + ch).join('') : m[1];
}
return (0xff000000 | parseInt(hex, 16)) | 0;
}
/** An ARGB int as `#rrggbb` (the alpha is dropped: JSCalendar colours are opaque). */
export function argbToCss(value: unknown): string | null {
if (value === null || value === undefined || value === '') return null;
const n = Number(value);
if (!Number.isFinite(n)) return null;
return `#${((n >>> 0) & 0xffffff).toString(16).padStart(6, '0')}`;
}
+252
View File
@@ -0,0 +1,252 @@
/**
* JSCalendar LocalDateTimes in an IANA zone ↔ UTC instants, for mapping
* events to CalendarProvider's millisecond columns (docs/device-sync.md,
* "Timing" and "DST edges"). Only `Intl` is used, so it behaves the same on
* Hermes and in vitest, whatever zone the process runs in.
*
* RFC 5545 §3.3.5 decides the transition hours: a wall time in a DST gap is
* read with the offset before the gap (it moves forward by the gap), and a
* wall time in an overlap is the earlier of its two instants. The app's
* `localDateTimeToInstant` (lib/calendar-timezone.ts) gets one of the two
* wrong depending on the sign of the zone's offset, and its module imports
* the settings store, so it is not reused here.
*/
export interface WallClock {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
export type LocalTimeKind = 'ok' | 'gap' | 'overlap';
export const DAY_MS = 86_400_000;
const HOUR_MS = 3_600_000;
const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:[.,]\d+)?)?)?/;
/**
* The fields of a LocalDateTime (`2026-10-25T02:30:00`) or a date
* (`2026-10-25`, read as midnight). Like Stalwart, a fraction and a trailing
* `Z` or offset are ignored. Null for anything that is not a real date-time.
*/
export function parseLocalDateTime(value: unknown): WallClock | null {
if (typeof value !== 'string') return null;
const m = LOCAL_DATE_TIME.exec(value.trim());
if (!m) return null;
const wall: WallClock = {
year: Number(m[1]),
month: Number(m[2]),
day: Number(m[3]),
hour: Number(m[4] ?? 0),
minute: Number(m[5] ?? 0),
second: Number(m[6] ?? 0),
};
if (wall.hour > 23 || wall.minute > 59 || wall.second > 59) return null;
// Date arithmetic rolls 2026-02-30 over into March; a real date survives the round trip.
const back = wallFromMs(wallMs(wall));
return back.month === wall.month && back.day === wall.day ? wall : null;
}
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
export function formatLocalDateTime(wall: WallClock): string {
return `${pad(wall.year, 4)}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}`;
}
/** `YYYY-MM-DD` of a wall clock. */
export function formatDate(wall: WallClock): string {
return `${pad(wall.year, 4)}-${pad(wall.month)}-${pad(wall.day)}`;
}
/** The wall clock read as if it were UTC, in milliseconds (years below 100 included). */
export function wallMs(wall: WallClock): number {
const d = new Date(0);
d.setUTCFullYear(wall.year, wall.month - 1, wall.day);
d.setUTCHours(wall.hour, wall.minute, wall.second, 0);
return d.getTime();
}
export function wallFromMs(ms: number): WallClock {
const d = new Date(ms);
return {
year: d.getUTCFullYear(),
month: d.getUTCMonth() + 1,
day: d.getUTCDate(),
hour: d.getUTCHours(),
minute: d.getUTCMinutes(),
second: d.getUTCSeconds(),
};
}
/** Wall-clock arithmetic: calendar days, then an exact amount of wall time. */
export function addToWall(wall: WallClock, days: number, ms = 0): WallClock {
return wallFromMs(wallMs(wall) + days * DAY_MS + ms);
}
// ─── Zones ──────────────────────────────────────────────
const zoneCache = new Map<string, string | null>();
/**
* The zone as Intl names it (canonical case, aliases resolved: `US/Eastern`
* → `America/New_York`, `Etc/UTC` → `UTC`), or null when Intl does not know
* it. Android's TimeZone silently turns an unknown id into GMT and Stalwart
* turns one into a floating event, so every zone is checked before either
* write.
*/
export function canonicalZone(zone: unknown): string | null {
if (typeof zone !== 'string' || !zone) return null;
let canonical = zoneCache.get(zone);
if (canonical === undefined) {
try {
canonical = new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions().timeZone ?? zone;
} catch {
canonical = null;
}
zoneCache.set(zone, canonical);
}
return canonical;
}
export function isValidTimeZone(zone: unknown): zone is string {
return canonicalZone(zone) !== null;
}
const formatters = new Map<string, Intl.DateTimeFormat>();
function formatter(zone: string): Intl.DateTimeFormat {
let f = formatters.get(zone);
if (!f) {
f = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
hourCycle: 'h23',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
});
formatters.set(zone, f);
}
return f;
}
/** Wall-clock fields of an instant in `zone` (whole seconds). */
export function wallClockAt(utcMs: number, zone: string): WallClock {
const fields: Record<string, number> = {};
for (const part of formatter(zone).formatToParts(new Date(floorSecond(utcMs)))) {
if (part.type !== 'literal') fields[part.type] = Number(part.value);
}
return {
year: fields.year,
month: fields.month,
day: fields.day,
// Some engines still say "24" at midnight despite h23.
hour: fields.hour === 24 ? 0 : fields.hour,
minute: fields.minute,
second: fields.second,
};
}
function floorSecond(ms: number): number {
return Math.floor(ms / 1000) * 1000;
}
/** The zone's UTC offset at an instant in ms, east positive. */
export function offsetAt(utcMs: number, zone: string): number {
const at = floorSecond(utcMs);
return wallMs(wallClockAt(at, zone)) - at;
}
export interface ResolvedLocalTime {
/** The instant per RFC 5545: gap → the offset before it; overlap → the earlier one. */
utc: number;
kind: LocalTimeKind;
}
/**
* The instant of a wall clock in `zone`, and whether the wall time is
* normal, skipped (gap) or repeated (overlap). Assumes at most one offset
* change within a day of the wall time, which holds for every real zone.
*/
export function resolveWallClock(wall: WallClock, zone: string): ResolvedLocalTime {
const w = wallMs(wall);
const before = offsetAt(w - DAY_MS, zone);
const after = offsetAt(w + DAY_MS, zone);
const tBefore = w - before;
const tAfter = w - after;
const okBefore = offsetAt(tBefore, zone) === before;
const okAfter = offsetAt(tAfter, zone) === after;
if (okBefore && okAfter) {
return tBefore === tAfter ? { utc: tBefore, kind: 'ok' } : { utc: Math.min(tBefore, tAfter), kind: 'overlap' };
}
if (okBefore) return { utc: tBefore, kind: 'ok' };
if (okAfter) return { utc: tAfter, kind: 'ok' };
return { utc: tBefore, kind: 'gap' };
}
/** A LocalDateTime in `zone` as a UTC instant (RFC 5545 semantics), or null when unreadable. */
export function localToUtc(value: string, zone: string): number | null {
const wall = parseLocalDateTime(value);
const z = canonicalZone(zone);
if (!wall || !z) return null;
return resolveWallClock(wall, z).utc;
}
/** Whether a LocalDateTime is an ordinary, a skipped or a repeated wall time in `zone`. */
export function classifyLocal(value: string, zone: string): LocalTimeKind | null {
const wall = parseLocalDateTime(value);
const z = canonicalZone(zone);
if (!wall || !z) return null;
return resolveWallClock(wall, z).kind;
}
/** The instant as a LocalDateTime in `zone`. Both instants of an overlap give the same text. */
export function utcToLocal(utcMs: number, zone: string): string {
return formatLocalDateTime(wallClockAt(utcMs, canonicalZone(zone) ?? 'UTC'));
}
/** UTC midnight of a LocalDateTime's date: how CalendarProvider stores all-day times. */
export function dateToUtcMidnight(value: string): number | null {
const wall = parseLocalDateTime(value);
return wall ? wallMs({ ...wall, hour: 0, minute: 0, second: 0 }) : null;
}
/** The UTC date of an instant as a LocalDateTime at midnight (`2026-10-08T00:00:00`). */
export function utcMidnightToDate(utcMs: number): string {
return `${formatDate(wallFromMs(utcMs))}T00:00:00`;
}
/**
* A fixed-offset zone for an instant: `Etc/GMT-2` for UTC+2 (the Etc names
* flip the sign), `Etc/UTC` when the offset is zero or not a whole number of
* hours. Stalwart drops a start whose local time is repeated in its zone; the
* same instant and wall time in a fixed-offset zone survive (checked on the
* live server).
*/
export function fixedOffsetZone(offsetMs: number): string {
const hours = offsetMs / HOUR_MS;
if (!Number.isInteger(hours) || hours === 0 || hours < -12 || hours > 14) return 'Etc/UTC';
return `Etc/GMT${hours > 0 ? '-' : '+'}${Math.abs(hours)}`;
}
/** Compares two LocalDateTimes by their fields (a fraction or suffix is ignored). */
export function sameLocalDateTime(a: unknown, b: unknown): boolean {
const wa = parseLocalDateTime(a);
const wb = parseLocalDateTime(b);
if (!wa || !wb) return wa === wb && a === b;
return wallMs(wa) === wallMs(wb);
}
/** Orders LocalDateTimes (ISO text compares chronologically once normalised). */
export function compareLocal(a: string, b: string): number {
const wa = parseLocalDateTime(a);
const wb = parseLocalDateTime(b);
if (!wa || !wb) return a < b ? -1 : a > b ? 1 : 0;
return wallMs(wa) - wallMs(wb);
}
+4 -4
View File
@@ -25,10 +25,11 @@ import {
type SubscriptionCalendar,
type TeardownOptions,
} from './engine';
import { calendarPlanner } from './calendar/planner';
import { emptyStats } from './engine/report';
import { openJmapPort } from './jmap/client-port';
import { createProviderPort, finishRun, getSyncSettings, isRunCancelled, showSyncProblem } from './native';
import type { CalendarPlanner, ContactsPlanner } from './planner';
import type { ContactsPlanner } from './planner';
import type { Authority, RunPayload, RunReport } from './types';
// Mirrors the persist names of the locale and feed-subscription stores; read
@@ -38,13 +39,12 @@ const SUBSCRIPTIONS_STORAGE_KEY = 'calendar-subscriptions';
/** Where "sign in again" leads (the accounts pane), as for a registry eviction. */
const SIGN_IN_URI = 'bulwarkmobile://settings/account';
// The contacts and calendar planners land in the next
// commits. Until then a run fails before it touches a row.
// The contacts planner lands in the next
// commit. Until then a run fails before it touches a row.
function notWired(): never {
throw new Error('Device sync planners are not wired into this build yet');
}
const contactsPlanner = new Proxy({}, { get: notWired }) as ContactsPlanner;
const calendarPlanner = new Proxy({}, { get: notWired }) as CalendarPlanner;
/** Random numbers from the platform CSPRNG, buffered (uids and map keys of device-made items). */
function secureRandom(): RandomSource {