diff --git a/src/device-sync/__tests__/calendar/engine.test.ts b/src/device-sync/__tests__/calendar/engine.test.ts index d7d30337..9729c4cf 100644 --- a/src/device-sync/__tests__/calendar/engine.test.ts +++ b/src/device-sync/__tests__/calendar/engine.test.ts @@ -17,6 +17,47 @@ function real(options: Parameters[0] = {}): Harness { type Overrides = Record>; describe('calendar device edits through the engine', () => { + it('creates an event and an occurrence edit an app left without a status as confirmed ones', async () => { + const h = real(); + const series = h.server.addEvent('a', { + uid: 'weekly-uid', + title: 'Weekly', + start: '2026-10-05T08:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 8 }, + calendarIds: { [h.calendar]: true }, + }); + await h.run(CALENDAR_AUTHORITY); + const master = h.events().find((e) => e[Events._SYNC_ID] === `a/${series}`)!; + const rowId = Number(master[Events.CALENDAR_ID]); + + // Google Calendar leaves STATUS NULL on the events and exceptions it inserts. + const created = h.device.user.insertEvent(rowId, { + [Events.TITLE]: 'New from Google Calendar', + [Events.DTSTART]: Date.UTC(2026, 9, 14, 13), + [Events.DTEND]: Date.UTC(2026, 9, 14, 14), + [Events.EVENT_TIMEZONE]: 'Europe/Berlin', + [Events.STATUS]: null, + }); + h.device.user.insertException(Number(master._id), Date.UTC(2026, 10, 9, 7), { + [Events.TITLE]: 'Only Nov 9', + [Events.DTSTART]: Date.UTC(2026, 10, 9, 7), + [Events.DTEND]: Date.UTC(2026, 10, 9, 8), + [Events.STATUS]: null, + }); + expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1, updated: 1 } } }); + + const event = h.server.all('CalendarEvent', 'a').find((e) => e.title === 'New from Google Calendar')!; + expect(event.status).toBeUndefined(); + const overrides = h.server.get('CalendarEvent', 'a', series)!.recurrenceOverrides as Overrides; + expect(overrides['2026-11-09T08:00:00']).toMatchObject({ title: 'Only Nov 9' }); + expect(overrides['2026-11-09T08:00:00'].status).toBeUndefined(); + // The rows hold the default, confirmed, and are clean. + expect(h.events().find((e) => Number(e._id) === created)).toMatchObject({ [Events.STATUS]: 1, [Events.DIRTY]: 0 }); + expect(h.events().filter((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${series}`).map((e) => [e[Events.STATUS], e[Events.DIRTY]])).toEqual([[1, 0]]); + }); + it('keeps a description and location cleared on one occurrence cleared', async () => { const h = real(); const id = h.server.addEvent('a', { diff --git a/src/device-sync/__tests__/calendar/exceptions.test.ts b/src/device-sync/__tests__/calendar/exceptions.test.ts index 82007b54..0b35614d 100644 --- a/src/device-sync/__tests__/calendar/exceptions.test.ts +++ b/src/device-sync/__tests__/calendar/exceptions.test.ts @@ -103,6 +103,21 @@ describe('calendar planner: recurrence edits made on the device', () => { expect(h.row(x.eventId)).toBeUndefined(); }); + it('does not make an occurrence an app left without a status (Google Calendar\'s "this event") tentative', async () => { + const event = single({ recurrenceRule: { frequency: 'weekly', count: 5 } }); + const { h, id } = await synced(event); + h.fake.user.insertException(id, utc('2026-10-13T10:00:00Z'), { + [Events.TITLE]: 'Only this Tuesday', [Events.DTSTART]: utc('2026-10-13T10:00:00Z'), [Events.DTEND]: utc('2026-10-13T11:00:00Z'), [Events.STATUS]: null, + }); + const local = (await h.local('e1'))!; + const plan = calendarPlanner.planUpload(local, h.ctx); + const overrides = patchOf(plan).patch.recurrenceOverrides as Record>; + expect(overrides['2026-10-13T12:00:00']).toEqual({ title: 'Only this Tuesday', start: '2026-10-13T12:00:00', duration: 'PT1H' }); + await accept(h, event, local, plan); + const [x] = (await h.local('e1'))!.exceptions; + expect(x.cells).toMatchObject({ [Events.STATUS]: 1, [Events.DIRTY]: 0 }); + }); + it('clears an occurrence\'s own description and location instead of letting the series\' come back', async () => { const event = weekly(); event.description = 'Daily agenda'; diff --git a/src/device-sync/__tests__/calendar/lifecycle.test.ts b/src/device-sync/__tests__/calendar/lifecycle.test.ts index 994f2972..5acc036e 100644 --- a/src/device-sync/__tests__/calendar/lifecycle.test.ts +++ b/src/device-sync/__tests__/calendar/lifecycle.test.ts @@ -100,6 +100,37 @@ describe('calendar planner: new events', () => { expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none'); }); + it('creates an event an app left without a status (Google Calendar) without making it tentative', async () => { + const h = await new Harness().setup(); + // CalendarProvider has no default for STATUS: an app that does not write it leaves it NULL. + const id = h.fake.user.insertEvent(h.rowIds.get('b')!, { + [Events.TITLE]: 'From Google Calendar', [Events.DTSTART]: utc('2026-10-14T15:00:00Z'), [Events.DTEND]: utc('2026-10-14T16:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: null, + }); + const { local, create } = await claimAndCreate(h, id); + expect(create.object).not.toHaveProperty('status'); + const server = { ...(create.object as CalendarEventWire), id: 'g1' } as CalendarEventWire; + await h.apply(calendarPlanner.planAccepted(local, server, h.ctx).ops); + // Written as JSCalendar's default, confirmed. + expect(h.row(id)).toMatchObject({ [Events.STATUS]: 1, [Events.DIRTY]: 0 }); + expect(calendarPlanner.planDownload(server, (await h.local('g1'))!, h.ctx).effect).toBe('none'); + }); + + it('leaves out exclusions a new series inherited from before its start (Etar\'s split copies the EXDATE)', async () => { + const h = await new Harness().setup(); + const id = h.fake.user.insertEvent(h.rowIds.get('b')!, { + [Events.TITLE]: 'Weekly (new time)', + [Events.DTSTART]: utc('2026-11-16T09:00:00Z'), + [Events.DURATION]: 'P3600S', + [Events.RRULE]: 'FREQ=WEEKLY;COUNT=3;BYDAY=MO', + [Events.EVENT_TIMEZONE]: 'Europe/Berlin', + // The old series' exclusions (Oct 12, Nov 2) and one of this series (Nov 23). + [Events.EXDATE]: '20261012T060000Z,20261102T070000Z,20261123T090000Z', + [Events.STATUS]: 1, + }); + const { create } = await claimAndCreate(h, id); + expect(create.object.recurrenceOverrides).toEqual({ '2026-11-23T10:00:00': { excluded: true } }); + }); + it('invites the attendees of a new event as its organizer', async () => { const h = await new Harness().setup(); const id = h.fake.user.insertEvent( @@ -232,6 +263,20 @@ describe('calendar planner: accepted uploads, merges and deletions', () => { expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Server title', [Events.DIRTY]: 0 }); }); + it('never reads a status an app left NULL as an edit, in uploads or merges', async () => { + const { h, id } = await synced(weekly()); + expect(h.row(id)[Events.STATUS]).toBe(0); + // A row an app wrote without STATUS (only an insert can leave it NULL), then edited. + h.fake.table('events').get(id)![Events.STATUS] = null; + h.fake.user.updateEvent(id, { [Events.TITLE]: 'Renamed' }); + expect(actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))[0]).toMatchObject({ kind: 'update', patch: { title: 'Renamed' } }); + expect(Object.keys((actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))[0] as { patch: object }).patch)).toEqual(['title']); + // The server's status change meanwhile is no conflict: the row takes it, the title stays for the upload. + const plan = await h.download({ ...weekly(), status: 'cancelled' }); + expect(plan).toMatchObject({ conflicts: 0, stillDirty: true }); + expect(h.row(id)).toMatchObject({ [Events.STATUS]: 2, [Events.TITLE]: 'Renamed', [Events.DIRTY]: 1 }); + }); + 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' }); @@ -362,6 +407,17 @@ describe('calendar planner: pairs', () => { expect(pairs.map((p) => p.actions)).toEqual([[{ kind: 'update', id: 'e1', patch: { recurrenceRule: null } }]]); }); + it('pairs a copy an app inserted without a status (Google Calendar\'s "Copy to" + delete) with the original', async () => { + const { h, id } = await synced(single()); + h.fake.user.deleteEvent(id); + 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]: null, [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: { 'calendarIds/b': null, 'calendarIds/w': true } }]]); + }); + it('does not pair unrelated rows', async () => { const { h, id } = await synced(single()); h.fake.user.deleteEvent(id); diff --git a/src/device-sync/calendar/create.ts b/src/device-sync/calendar/create.ts index bad4ec4f..e1ed3fa3 100644 --- a/src/device-sync/calendar/create.ts +++ b/src/device-sync/calendar/create.ts @@ -54,9 +54,11 @@ export function computeCreate(local: LocalEvent, uid: string, calendarId: string title: titleValue(cells), start: t.start, duration: t.duration, - status: statusFromDevice(cells[Events.STATUS]), freeBusyStatus: availabilityFromDevice(cells[Events.AVAILABILITY]), }; + // A row without STATUS gets JSCalendar's default, confirmed. + const status = statusFromDevice(cells[Events.STATUS]); + if (status) object.status = status; if (t.showWithoutTime) object.showWithoutTime = true; else object.timeZone = t.timeZone; const description = descriptionValue(cells); @@ -88,8 +90,10 @@ export function computeCreate(local: LocalEvent, uid: string, calendarId: string return key; }; for (const entry of exdateEntries(sideOfRow(local))) { - const key = checked(exdateEntryKey(entry, zone)); - if (key) overrides[key] = { excluded: true }; + const key = exdateEntryKey(entry, zone); + // Etar's "this and following" copies the old series' EXDATE: dates before the start are none of this series. + if (!key || key < t.start) continue; + overrides[checked(key)!] = { excluded: true }; } for (const x of local.exceptions) { const key = checked(x.recurrenceId); diff --git a/src/device-sync/calendar/merge.ts b/src/device-sync/calendar/merge.ts index 53715e5a..dc65b48e 100644 --- a/src/device-sync/calendar/merge.ts +++ b/src/device-sync/calendar/merge.ts @@ -42,6 +42,7 @@ import { attendeesByKey, baselineOfImage, columnUnitDiffers, + deviceChangedUnit, exdateEntries, isLossyEditor, remindersDiffer, @@ -135,7 +136,7 @@ export function mergeRow(input: RowMergeInput): RowMerge { }; for (const unit of input.isException ? EXCEPTION_COLUMN_UNITS : MASTER_COLUMN_UNITS) { - const local = (dirty || forced.has(unit)) && columnUnitDiffers(unit, cur, bl); + const local = (dirty || forced.has(unit)) && deviceChangedUnit(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]) { diff --git a/src/device-sync/calendar/pairs.ts b/src/device-sync/calendar/pairs.ts index 8f4eeb81..80513bd2 100644 --- a/src/device-sync/calendar/pairs.ts +++ b/src/device-sync/calendar/pairs.ts @@ -21,6 +21,7 @@ import { GroupBuilder, assertRow, deleteRow, updateRow } from './rows'; import { rruleParts } from './rrule'; import { sendsSchedulingMessages } from './scheduling'; import { put } from './upload'; +import { isUnsetStatus } from './values'; /** What must be equal for two rows to be one event (the calendar and the rule aside). */ const PAIR_COLUMNS = [ @@ -58,6 +59,8 @@ export interface PairKind { /** 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) { + // A copy inserted without STATUS (Google Calendar) says nothing about it. + if (c === Events.STATUS && (isUnsetStatus(deleted.cells[c]) || isUnsetStatus(fresh.cells[c]))) continue; if (String(deleted.cells[c] ?? '') !== String(fresh.cells[c] ?? '')) return null; } const allDay = Number(fresh.cells[Events.ALL_DAY] ?? 0) === 1; diff --git a/src/device-sync/calendar/units.ts b/src/device-sync/calendar/units.ts index b397857e..2046f8fa 100644 --- a/src/device-sync/calendar/units.ts +++ b/src/device-sync/calendar/units.ts @@ -12,6 +12,7 @@ import { MASTER_CELLS, EXCEPTION_CELLS, makeBaseline, sameCell } from './columns import { exdateSet } from './exdate'; import { sameReminders } from './reminders'; import type { RowImage } from './image'; +import { isUnsetStatus } from './values'; export const COLUMN_UNITS: Record = { title: [Events.TITLE], @@ -73,6 +74,16 @@ export function columnUnitDiffers(unit: string, a: Side, b: Side): boolean { return (COLUMN_UNITS[unit] ?? []).some((c) => !sameCell(c, a.cells[c], b.cells[c])); } +/** + * Whether the device changed a column unit against what it is measured by + * (the baseline, or the plain instance of a new exception row). A STATUS an + * app left NULL is no change: it sets no status (`statusFromDevice`). + */ +export function deviceChangedUnit(unit: string, current: Side, reference: Side): boolean { + if (unit === 'status' && isUnsetStatus(current.cells[Events.STATUS])) return false; + return columnUnitDiffers(unit, current, reference); +} + /** Attendee rows by address key (the user's aliases are one key). */ export function attendeesByKey(rows: Row[], ctx: SelfContext): Map { const out = new Map(); diff --git a/src/device-sync/calendar/upload.ts b/src/device-sync/calendar/upload.ts index fb877a51..012f611b 100644 --- a/src/device-sync/calendar/upload.ts +++ b/src/device-sync/calendar/upload.ts @@ -58,6 +58,7 @@ import { MASTER_COLUMN_UNITS, baselineOfImage, columnUnitDiffers, + deviceChangedUnit, exdateEntries, isLossyEditor, remindersDiffer, @@ -219,7 +220,7 @@ export function computeUpdate(local: LocalEvent, ctx: CalendarContext): UploadCo const changed = (unit: string) => { if (!bl) return false; const forced = local.split === 'source' && unit === 'rule'; - return (local.dirty || forced) && columnUnitDiffers(unit, cur, bl); + return (local.dirty || forced) && deviceChangedUnit(unit, cur, bl); }; for (const unit of MASTER_COLUMN_UNITS) { @@ -444,7 +445,7 @@ function exceptionChanges( 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; + if (unit !== 'timing' && !deviceChangedUnit(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; diff --git a/src/device-sync/calendar/values.ts b/src/device-sync/calendar/values.ts index 402d0639..49449ac9 100644 --- a/src/device-sync/calendar/values.ts +++ b/src/device-sync/calendar/values.ts @@ -27,13 +27,24 @@ export function statusToDevice(status: unknown): number { } } -export function statusFromDevice(value: unknown): 'confirmed' | 'tentative' | 'cancelled' { +/** + * STATUS → `status`; null when the row has none. CalendarProvider has no + * default for STATUS, and apps that do not model it (Google Calendar) leave + * it NULL on the events and exceptions they insert: that sets no status. + */ +export function statusFromDevice(value: unknown): 'confirmed' | 'tentative' | 'cancelled' | null { + if (isUnsetStatus(value)) return null; const n = Number(value); if (n === EventStatus.TENTATIVE) return 'tentative'; if (n === EventStatus.CANCELED) return 'cancelled'; return 'confirmed'; } +/** A STATUS cell an app left NULL (see `statusFromDevice`). */ +export function isUnsetStatus(value: unknown): boolean { + return value === null || value === undefined || value === ''; +} + /** `freeBusyStatus` → AVAILABILITY; JSCalendar's default is busy. */ export function availabilityToDevice(freeBusy: unknown): number { return String(freeBusy ?? '').toLowerCase() === 'free' ? EventAvailability.FREE : EventAvailability.BUSY;