diff --git a/src/device-sync/__tests__/calendar/calendars.test.ts b/src/device-sync/__tests__/calendar/calendars.test.ts new file mode 100644 index 00000000..98c82dc3 --- /dev/null +++ b/src/device-sync/__tests__/calendar/calendars.test.ts @@ -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 }).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 }).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({}); + }); +}); diff --git a/src/device-sync/__tests__/calendar/download.test.ts b/src/device-sync/__tests__/calendar/download.test.ts new file mode 100644 index 00000000..ab00eea5 --- /dev/null +++ b/src/device-sync/__tests__/calendar/download.test.ts @@ -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); + 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 }; + 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'); + }); +}); diff --git a/src/device-sync/__tests__/calendar/duration.test.ts b/src/device-sync/__tests__/calendar/duration.test.ts new file mode 100644 index 00000000..e8affbc2 --- /dev/null +++ b/src/device-sync/__tests__/calendar/duration.test.ts @@ -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); + }); +}); diff --git a/src/device-sync/__tests__/calendar/echo.test.ts b/src/device-sync/__tests__/calendar/echo.test.ts new file mode 100644 index 00000000..daac115e --- /dev/null +++ b/src/device-sync/__tests__/calendar/echo.test.ts @@ -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) { + 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]> = [ + ['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']); + }); +}); diff --git a/src/device-sync/__tests__/calendar/edge-cases.test.ts b/src/device-sync/__tests__/calendar/edge-cases.test.ts new file mode 100644 index 00000000..b32cb6e4 --- /dev/null +++ b/src/device-sync/__tests__/calendar/edge-cases.test.ts @@ -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; + // The series' description, location and attendees stay on the instance. + expect(override).toMatchObject({ description: 'desc', locations: weekly().locations, participants: weekly().participants }); + }); +}); diff --git a/src/device-sync/__tests__/calendar/exceptions.test.ts b/src/device-sync/__tests__/calendar/exceptions.test.ts new file mode 100644 index 00000000..540f73a6 --- /dev/null +++ b/src/device-sync/__tests__/calendar/exceptions.test.ts @@ -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; + +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 { + 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; + 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; + 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); + }); +}); diff --git a/src/device-sync/__tests__/calendar/harness.ts b/src/device-sync/__tests__/calendar/harness.ts new file mode 100644 index 00000000..e8f5e7af --- /dev/null +++ b/src/device-sync/__tests__/calendar/harness.ts @@ -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 = { + 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(); + 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 { + 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 { + 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 { + 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 { + return (await this.query('calendars', calendarPlanner.calendarColumns)).map(calendarPlanner.decodeCalendar); + } + + async events(): Promise { + 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 { + return (await this.events()).find((e) => e.syncId === `${ACCT}/${id}`) ?? null; + } + + async byRow(eventId: number): Promise { + 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 { + 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).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)[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 { + 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); diff --git a/src/device-sync/__tests__/calendar/lifecycle.test.ts b/src/device-sync/__tests__/calendar/lifecycle.test.ts new file mode 100644 index 00000000..994f2972 --- /dev/null +++ b/src/device-sync/__tests__/calendar/lifecycle.test.ts @@ -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; + +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; + 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); + 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'], + ]); + }); +}); diff --git a/src/device-sync/__tests__/calendar/robustness.test.ts b/src/device-sync/__tests__/calendar/robustness.test.ts new file mode 100644 index 00000000..1abc6480 --- /dev/null +++ b/src/device-sync/__tests__/calendar/robustness.test.ts @@ -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 }; + 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 }>; + 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'); + }); +}); diff --git a/src/device-sync/__tests__/calendar/rrule.test.ts b/src/device-sync/__tests__/calendar/rrule.test.ts new file mode 100644 index 00000000..3af02ed6 --- /dev/null +++ b/src/device-sync/__tests__/calendar/rrule.test.ts @@ -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']); + }); +}); diff --git a/src/device-sync/__tests__/calendar/upload.test.ts b/src/device-sync/__tests__/calendar/upload.test.ts new file mode 100644 index 00000000..5e22fcfb --- /dev/null +++ b/src/device-sync/__tests__/calendar/upload.test.ts @@ -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; + +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: '

Hi there

', 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]>([ + ['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); + 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: '

Hi there

', 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); + 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; + // 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); + 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); + }); +}); diff --git a/src/device-sync/__tests__/calendar/zoned-time.test.ts b/src/device-sync/__tests__/calendar/zoned-time.test.ts new file mode 100644 index 00000000..59d2b7af --- /dev/null +++ b/src/device-sync/__tests__/calendar/zoned-time.test.ts @@ -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'); + }); +}); diff --git a/src/device-sync/__tests__/fakes/fake-provider.ts b/src/device-sync/__tests__/fakes/fake-provider.ts index 3a611bb2..1acb2f6a 100644 --- a/src/device-sync/__tests__/fakes/fake-provider.ts +++ b/src/device-sync/__tests__/fakes/fake-provider.ts @@ -98,7 +98,7 @@ const COLUMNS: Record> = { 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)), diff --git a/src/device-sync/android-columns.ts b/src/device-sync/android-columns.ts index dc689821..e53eb262 100644 --- a/src/device-sync/android-columns.ts +++ b/src/device-sync/android-columns.ts @@ -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', diff --git a/src/device-sync/calendar/accepted.ts b/src/device-sync/calendar/accepted.ts new file mode 100644 index 00000000..f26c35f0 --- /dev/null +++ b/src/device-sync/calendar/accepted.ts @@ -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; + /** By exception row id. */ + exceptions: Map>; + /** Exception rows whose instance is excluded on the server now. */ + removed: Set; +} + +function unitColumns(unit: string): readonly string[] { + return COLUMN_UNITS[unit] ?? (unit.startsWith('exdate:') ? [Events.EXDATE] : []); +} + +function uploadedBaseline(row: LocalEventRow, isException: boolean, units: Set | 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 }; +} diff --git a/src/device-sync/calendar/attendees.ts b/src/device-sync/calendar/attendees.ts new file mode 100644 index 00000000..0e748677 --- /dev/null +++ b/src/device-sync/calendar/attendees.ts @@ -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 | 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(); + 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; +} diff --git a/src/device-sync/calendar/calendars.ts b/src/device-sync/calendar/calendars.ts new file mode 100644 index 00000000..d1fe6302 --- /dev/null +++ b/src/device-sync/calendar/calendars.ts @@ -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; + +/** 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([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(); + for (const row of local) if (row.syncId && !rows.has(row.syncId)) rows.set(row.syncId, row); + const wanted = new Set(); + 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 }; +} diff --git a/src/device-sync/calendar/columns.ts b/src/device-sync/calendar/columns.ts new file mode 100644 index 00000000..c887d253 --- /dev/null +++ b/src/device-sync/calendar/columns.ts @@ -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([...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([ + 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, b: Set): 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:`, 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>(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) }; +} diff --git a/src/device-sync/calendar/create.ts b/src/device-sync/calendar/create.ts new file mode 100644 index 00000000..bad4ec4f --- /dev/null +++ b/src/device-sync/calendar/create.ts @@ -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/`, 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; + 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 = { + '@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 = {}; + 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, + 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 = {}; + 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 | 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 = {}; + 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[] { + 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 = { 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]; +} diff --git a/src/device-sync/calendar/decode.ts b/src/device-sync/calendar/decode.ts new file mode 100644 index 00000000..d162ecf5 --- /dev/null +++ b/src/device-sync/calendar/decode.ts @@ -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>(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>(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(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(); + 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(); + 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(); + 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(); + const uidsOf = (m: LocalEvent) => { + const uids = new Set(); + 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>(row[Calendars.CAL_SYNC3]); + const shadow = parseJsonColumn(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; +} diff --git a/src/device-sync/calendar/duration.ts b/src/device-sync/calendar/duration.ts new file mode 100644 index 00000000..3f1f9682 --- /dev/null +++ b/src/device-sync/calendar/duration.ts @@ -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 + * ``, `T` ignored, `M` always minutes, no fractions and + * no `Y`. All-day rows must say `PD`: CalendarProvider rewrites `PS` + * 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: `PD`, `THMS` 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: `PD` for all-day rows (at least one day; a time + * part is rounded up), `PS` 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; +} diff --git a/src/device-sync/calendar/edits.ts b/src/device-sync/calendar/edits.ts new file mode 100644 index 00000000..2a0eef2f --- /dev/null +++ b/src/device-sync/calendar/edits.ts @@ -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; + +/** 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; + /** 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 | null | undefined, + edits: AttendeeEdit[], + self: SelfContext, + mintKey: MintKey, + lossy: boolean, +): AttendeeChanges { + const map = clone(participants ?? {}) as Record; + 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; +} + +/** + * 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, + 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, + name: unknown, + mintKey: MintKey, +): { changes: PathChange[]; locations: Record | null } { + const locations = clone((event.locations ?? {}) as Record>); + 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; +} diff --git a/src/device-sync/calendar/exceptions.ts b/src/device-sync/calendar/exceptions.ts new file mode 100644 index 00000000..9c5386df --- /dev/null +++ b/src/device-sync/calendar/exceptions.ts @@ -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(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; + +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; + 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 { + const map = event?.recurrenceOverrides; + return map && typeof map === 'object' ? (map as Record) : {}; +} diff --git a/src/device-sync/calendar/exdate.ts b/src/device-sync/calendar/exdate.ts new file mode 100644 index 00000000..c2a4358f --- /dev/null +++ b/src/device-sync/calendar/exdate.ts @@ -0,0 +1,114 @@ +/** + * The EXDATE column ↔ excluded recurrence overrides (docs/device-sync.md, + * "Recurrence"). AOSP's RecurrenceSet reads lines split by `\n`, each + * `[;][,…]` 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, 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(); + 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 { + const out = new Set(); + 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); +} diff --git a/src/device-sync/calendar/image.ts b/src/device-sync/calendar/image.ts new file mode 100644 index 00000000..039a4ed2 --- /dev/null +++ b/src/device-sync/calendar/image.ts @@ -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; + /** Recurrence ids of `{excluded: true}` overrides (the EXDATE column). */ + excluded: Set; + 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(); + const exceptions = new Map(); + 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 }; +} diff --git a/src/device-sync/calendar/merge.ts b/src/device-sync/calendar/merge.ts new file mode 100644 index 00000000..53715e5a --- /dev/null +++ b/src/device-sync/calendar/merge.ts @@ -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, +): 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; + /** 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(); + 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(); + const finalBaseline = new Set(); + 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, b: Set): 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(); + 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([...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; +} diff --git a/src/device-sync/calendar/pairs.ts b/src/device-sync/calendar/pairs.ts new file mode 100644 index 00000000..8f4eeb81 --- /dev/null +++ b/src/device-sync/calendar/pairs.ts @@ -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(); +} diff --git a/src/device-sync/calendar/planner.ts b/src/device-sync/calendar/planner.ts new file mode 100644 index 00000000..31e11f68 --- /dev/null +++ b/src/device-sync/calendar/planner.ts @@ -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; + +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 }> = []; + 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 = { 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 = { 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[]; ops: OpGroup }> { + const pairs: Array<{ deleted: LocalEvent; fresh: LocalEvent; actions: UploadAction[]; ops: OpGroup }> = []; + const used = new Set(); + 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 = { 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, +}; diff --git a/src/device-sync/calendar/reminders.ts b/src/device-sync/calendar/reminders.ts new file mode 100644 index 00000000..73d418f1 --- /dev/null +++ b/src/device-sync/calendar/reminders.ts @@ -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 | 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 { + 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(); + 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, + reminders: Row[], + mintKey: (taken: Iterable) => string, +): { alerts: Record; removed: string[]; added: Record } { + const wanted = reminders.map(rowReminder).filter((r): r is ReminderValue => r !== null); + const pool = new Map(); + for (const r of wanted) pool.set(keyOf(r), (pool.get(keyOf(r)) ?? 0) + 1); + const alerts: Record = {}; + 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 = {}; + 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 }; +} diff --git a/src/device-sync/calendar/rows.ts b/src/device-sync/calendar/rows.ts new file mode 100644 index 00000000..8c877a6c --- /dev/null +++ b/src/device-sync/calendar/rows.ts @@ -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): 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 = {}; + 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; +} diff --git a/src/device-sync/calendar/rrule.ts b/src/device-sync/calendar/rrule.ts new file mode 100644 index 00000000..66c5973c --- /dev/null +++ b/src/device-sync/calendar/rrule.ts @@ -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(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) => { + 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 | 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(); + 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, 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)[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); +} diff --git a/src/device-sync/calendar/scheduling.ts b/src/device-sync/calendar/scheduling.ts new file mode 100644 index 00000000..f311ef50 --- /dev/null +++ b/src/device-sync/calendar/scheduling.ts @@ -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 | 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; + } +} diff --git a/src/device-sync/calendar/timing.ts b/src/device-sync/calendar/timing.ts new file mode 100644 index 00000000..502104a4 --- /dev/null +++ b/src/device-sync/calendar/timing.ts @@ -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 (`PD` all-day, `PS` 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 }; +} diff --git a/src/device-sync/calendar/units.ts b/src/device-sync/calendar/units.ts new file mode 100644 index 00000000..b397857e --- /dev/null +++ b/src/device-sync/calendar/units.ts @@ -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 = { + 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 { + const out = new Map(); + 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([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 { + return exdateSet(side.cells[Events.EXDATE], Number(side.cells[Events.ALL_DAY] ?? 0) === 1); +} diff --git a/src/device-sync/calendar/upload.ts b/src/device-sync/calendar/upload.ts new file mode 100644 index 00000000..d312f3b8 --- /dev/null +++ b/src/device-sync/calendar/upload.ts @@ -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(); + const serverParts = rruleParts(ruleText(server, zone)) ?? new Map(); + 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; + /** Exception rows (by id) whose changes are in the patch. */ + exceptionUnits: Map>; + /** Exception rows whose instance is excluded by the patch, or already was. */ + removedExceptions: Set; + 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; + /** One-level properties of existing overrides by recurrence id. */ + props: Map>; +} + +/** 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(); + const exceptionUnits = new Map>(); + const removedExceptions = new Set(); + 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, + ctx: CalendarContext, + lossy: boolean, +): { units: Set; rsvp: boolean } { + const units = new Set(); + 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 = {}; + 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): Override { + const occurrence = { + ...(master as unknown as Record), + recurrenceId: key, + recurrenceOverrides: master.recurrenceOverrides ?? {}, + start: undefined, + baseEventId: undefined, + }; + const withDetails = withNewOverrideDetails(occurrence as never, values as never) as Record; + 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 = 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 = {}; + 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 = {}; + 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; +} + diff --git a/src/device-sync/calendar/values.ts b/src/device-sync/calendar/values.ts new file mode 100644 index 00000000..402d0639 --- /dev/null +++ b/src/device-sync/calendar/values.ts @@ -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 | 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')}`; +} diff --git a/src/device-sync/calendar/zoned-time.ts b/src/device-sync/calendar/zoned-time.ts new file mode 100644 index 00000000..a2f56ff3 --- /dev/null +++ b/src/device-sync/calendar/zoned-time.ts @@ -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(); + +/** + * 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(); + +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 = {}; + 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); +} diff --git a/src/device-sync/task.ts b/src/device-sync/task.ts index 54d18436..6a1149d9 100644 --- a/src/device-sync/task.ts +++ b/src/device-sync/task.ts @@ -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 {