mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
@@ -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)),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>) : {};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user