diff --git a/src/device-sync/__tests__/calendar/engine.test.ts b/src/device-sync/__tests__/calendar/engine.test.ts index 76f4c661..c0e3dcc5 100644 --- a/src/device-sync/__tests__/calendar/engine.test.ts +++ b/src/device-sync/__tests__/calendar/engine.test.ts @@ -166,6 +166,91 @@ describe('calendar device edits through the engine', () => { expect(h.server.all('CalendarEvent', 'team')).toEqual([]); }); + describe("Etar's move (delete + insert) with other edits", () => { + /** The event columns Etar copies into the row it inserts when it moves an event. */ + const ETAR_COPY = [ + Events.TITLE, Events.DESCRIPTION, Events.EVENT_LOCATION, Events.STATUS, Events.AVAILABILITY, Events.ACCESS_LEVEL, + Events.EVENT_COLOR, Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY, Events.RRULE, Events.EXDATE, + ]; + function etarMove(h: Harness, masterId: number, calendarRowId: number, reminders: Array<{ minutes: number }>): number { + const old = h.events().find((e) => Number(e._id) === masterId)!; + const values = Object.fromEntries(ETAR_COPY.filter((c) => old[c] !== undefined).map((c) => [c, old[c]])); + h.device.user.deleteEvent(masterId); + return h.device.user.insertEvent(calendarRowId, values, { + reminders: reminders.map((r) => ({ [Reminders.MINUTES]: r.minutes, [Reminders.METHOD]: 1 })), + }); + } + const minutes = (h: Harness, eventId: number) => + h.device.rows('reminders').filter((r) => Number(r[Reminders.EVENT_ID]) === eventId).map((r) => Number(r[Reminders.MINUTES])); + + it('uploads a reminder saved with the move, and keeps the event', async () => { + const h = real(); + const work = h.server.addCalendar('a', { name: 'Work' }); + const id = h.server.addEvent('a', { + uid: 'lunch-uid', + title: 'Lunch', + start: '2026-10-06T12:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + keywords: { important: true }, + alerts: { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' } }, + calendarIds: { [h.calendar]: true }, + }); + await h.run(CALENDAR_AUTHORITY); + const master = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id); + const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id); + + const moved = etarMove(h, master, workRow, [{ minutes: 60 }]); + const report = await h.run(CALENDAR_AUTHORITY); + + expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, deleted: 0 } } }); + const event = h.server.get('CalendarEvent', 'a', id)!; + expect(event).toMatchObject({ uid: 'lunch-uid', calendarIds: { [work]: true }, keywords: { important: true } }); + const alerts = Object.values(event.alerts as Record).map((a) => a.trigger.offset); + expect(alerts).toEqual(['-PT1H']); + expect(h.events()).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${id}`, [Events.DIRTY]: 0 })]); + expect(minutes(h, moved)).toEqual([60]); + + const before = h.batches.log.length; + await h.run(CALENDAR_AUTHORITY); + expect(h.batches.log.slice(before).filter((ops) => ops.some((op) => op.op !== 'syncState' && op.op !== 'assert'))).toEqual([]); + }); + + it('keeps an occurrence edit that had not reached the server when the series moves', async () => { + const h = real(); + const work = h.server.addCalendar('a', { name: 'Work' }); + const id = h.server.addEvent('a', { + uid: 'standup-uid', + title: 'Standup', + start: '2026-09-28T09:00:00', + duration: 'PT15M', + timeZone: 'Europe/Berlin', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 }, + recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Planning' } }, + calendarIds: { [h.calendar]: true }, + }); + await h.run(CALENDAR_AUTHORITY); + const master = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id); + const exception = Number(h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!._id); + const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id); + // The occurrence is renamed on the device; before it syncs, Etar moves the series (it copies only unsynced exceptions). + h.device.user.updateEvent(exception, { [Events.TITLE]: 'Sprint planning' }); + + const moved = etarMove(h, master, workRow, []); + const report = await h.run(CALENDAR_AUTHORITY); + + expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, deleted: 0 } } }); + const event = h.server.get('CalendarEvent', 'a', id)!; + expect(event.calendarIds).toEqual({ [work]: true }); + expect((event.recurrenceOverrides as Overrides)['2026-09-29T09:00:00']).toMatchObject({ title: 'Sprint planning' }); + const rows = h.events(); + expect(rows.filter((e) => e[Events.ORIGINAL_ID] === null)).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${id}`, [Events.DIRTY]: 0 })]); + expect(rows.filter((e) => e[Events.ORIGINAL_ID] !== null)).toEqual([ + expect.objectContaining({ [Events.ORIGINAL_ID]: moved, [Events.CALENDAR_ID]: workRow, [Events.TITLE]: 'Sprint planning', [Events.DIRTY]: 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/lifecycle.test.ts b/src/device-sync/__tests__/calendar/lifecycle.test.ts index 5acc036e..a3cb9182 100644 --- a/src/device-sync/__tests__/calendar/lifecycle.test.ts +++ b/src/device-sync/__tests__/calendar/lifecycle.test.ts @@ -425,6 +425,94 @@ describe('calendar planner: pairs', () => { const events = await h.events(); expect(calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx)).toEqual([]); }); + + /** Etar's move of `single()` (delete + insert into Work), with `extra` columns and children on the new row. */ + const lunchColumns = { + [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, + }; + async function pairAndApply(h: Harness) { + 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).toHaveLength(1); + await h.apply(pairs[0].ops); + return pairs[0]; + } + const alert15 = { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'display' } } as CalendarEventWire['alerts']; + + it('keeps a reminder saved with the move for the next upload instead of taking it as the baseline', async () => { + const { h, id } = await synced(single({ alerts: alert15 })); + h.fake.user.deleteEvent(id); + const moved = h.fake.user.insertEvent(h.rowIds.get('w')!, lunchColumns, { reminders: [{ [Reminders.MINUTES]: 60, [Reminders.METHOD]: 1 }] }); + + const pair = await pairAndApply(h); + + expect(pair.actions).toEqual([{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]); + const after = (await h.local('e1'))!; + expect(after).toMatchObject({ eventId: moved, dirty: true }); + const [update] = actionsOf(calendarPlanner.planUpload(after, h.ctx)); + if (update.kind !== 'update') throw new Error('not an update'); + // Only the reminder is left to send: the move went up with the pair. + expect(Object.keys(update.patch).every((k) => k.startsWith('alerts/'))).toBe(true); + const alerts = serverApply(after.shadow!, update.patch).alerts!; + expect(Object.values(alerts).map((a) => (a.trigger as { offset?: string }).offset)).toEqual(['-PT1H']); + }); + + it('uploads an edit made before the move, which the new row copied', async () => { + const { h, id } = await synced(single({ description: 'Bring salad' })); + h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'Bring soup' }); + h.fake.user.deleteEvent(id); + h.fake.user.insertEvent(h.rowIds.get('w')!, { ...lunchColumns, [Events.DESCRIPTION]: 'Bring soup' }); + + await pairAndApply(h); + + const after = (await h.local('e1'))!; + expect(after.dirty).toBe(true); + expect(actionsOf(calendarPlanner.planUpload(after, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { description: 'Bring soup' } }]); + }); + + it('takes attendee rows an app re-inserted with constant details as they are, uploading nothing for them', async () => { + const participants = { + bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.net', name: 'Bob', roles: { attendee: true, optional: true }, participationStatus: 'accepted' }, + carol: { '@type': 'Participant', calendarAddress: 'mailto:carol@example.net', name: 'Carol', roles: { attendee: true }, participationStatus: 'declined' }, + } as CalendarEventWire['participants']; + const { h, id } = await synced(single({ participants, organizerCalendarAddress: 'mailto:boss@example.net' })); + const attendees = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [id]).map((a) => ({ + // Etar writes every attendee it re-inserts as a required attendee without a status. + [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]: 0, + })); + const organizer = h.row(id)[Events.ORGANIZER]; + h.fake.user.deleteEvent(id); + h.fake.user.insertEvent(h.rowIds.get('w')!, { ...lunchColumns, [Events.ORGANIZER]: organizer }, { attendees }); + + await pairAndApply(h); + + const after = (await h.local('e1'))!; + expect(after.dirty).toBe(false); + expect(calendarPlanner.planUpload({ ...after, dirty: true }, h.ctx).kind).toBe('clean'); + }); + + it('keeps the timing of a series turned single against the series, so a start moved before uploads', async () => { + const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } })); + // Moved an hour later in place, then turned into a single event (Etar: delete + insert). + h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z') }); + h.fake.user.deleteEvent(id); + h.fake.user.insertEvent(h.rowIds.get('b')!, { ...lunchColumns, [Events.DTSTART]: utc('2026-10-06T11:00:00Z'), [Events.DTEND]: utc('2026-10-06T12:00:00Z') }); + + const pair = await pairAndApply(h); + + expect(pair.actions).toEqual([{ kind: 'update', id: 'e1', patch: { recurrenceRule: null } }]); + const after = (await h.local('e1'))!; + expect(after.dirty).toBe(true); + expect(actionsOf(calendarPlanner.planUpload(after, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { start: '2026-10-06T13:00:00' } }]); + + // Without the earlier edit, the single event matches what the server holds after the pair: nothing left. + const plain = await synced(single({ recurrenceRule: { frequency: 'daily' } })); + plain.h.fake.user.deleteEvent(plain.id); + plain.h.fake.user.insertEvent(plain.h.rowIds.get('b')!, lunchColumns); + await pairAndApply(plain.h); + expect((await plain.h.local('e1'))!.dirty).toBe(false); + }); }); describe('calendar planner: baselines, zones and reminder owners', () => { diff --git a/src/device-sync/calendar/pairs.ts b/src/device-sync/calendar/pairs.ts index 80513bd2..1788bfcb 100644 --- a/src/device-sync/calendar/pairs.ts +++ b/src/device-sync/calendar/pairs.ts @@ -9,17 +9,20 @@ * target account and a destroy in the source. */ import { Attendees, Events } from '../android-columns'; -import type { CalendarContext, LocalEvent, LocalEventRow, OpGroup } from '../planner'; +import type { CalendarContext, EventBaseline, LocalEvent, LocalEventRow, OpGroup } from '../planner'; import type { Row } from '../types'; import type { CalendarEventWire } from '../wire'; import { exceptionRef, parseObjectRef } from '../common/ids'; +import { clone } from '../common/json'; 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 { linkedExceptionCount } from './merge'; +import { GroupBuilder, assertExceptionCount, assertRow, deleteRow, updateRow } from './rows'; import { rruleParts } from './rrule'; import { sendsSchedulingMessages } from './scheduling'; +import { MASTER_COLUMN_UNITS, deviceChangedUnit, exdateEntries, remindersDiffer, sideOfBaseline, sideOfRow } from './units'; import { put } from './upload'; import { isUnsetStatus } from './values'; @@ -101,24 +104,80 @@ export function pairPatch( return { patch, sendSchedulingMessages: scheduling }; } +const children = (row: LocalEventRow) => ({ attendees: row.attendees.map((a) => a.cells), reminders: row.reminders.map((r) => r.cells) }); + /** - * 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. + * The new row's baseline: the old row's (what the server held, as last + * written) with what the patch changed, the calendar and, for a series turned + * single, its rule and its timing as a single event. So an edit saved with the + * move, or made before it, still differs from the baseline and uploads next. + * Attendee rows are the app's re-insert (Etar writes every attendee it copies + * as required, without a status), so their details are no edits: they are + * taken as they are, like the reminders of a row that copied none. */ -export function pairOps(deleted: LocalEvent, fresh: LocalEvent, patch: PatchObject): OpGroup { +function pairedBaseline(deleted: LocalEvent, fresh: LocalEvent, kind: PairKind, ctx: CalendarContext): EventBaseline { + const own = makeBaseline(fresh.cells, MASTER_CELLS, children(fresh).attendees, children(fresh).reminders); + if (!deleted.baseline) return own; + const next = clone(deleted.baseline); + next.cells[Events.CALENDAR_ID] = own.cells[Events.CALENDAR_ID]; + next.attendees = own.attendees; + if (ctx.reminderOwner !== 'device' || !fresh.reminders.length) next.reminders = own.reminders; + if (kind.unrecur) { + const start = num(deleted.baseline.cells[Events.DTSTART]); + const length = lengthMs(deleted.baseline.cells); + next.cells[Events.DTEND] = start !== null && length !== null ? start + length : own.cells[Events.DTEND]; + next.cells[Events.DURATION] = null; + next.cells[Events.RRULE] = own.cells[Events.RRULE]; + next.cells[Events.EXDATE] = own.cells[Events.EXDATE]; + } + return next; +} + +/** Whether a master holds a change against `baseline` that an upload would look at. */ +function changedSince(row: LocalEventRow, baseline: EventBaseline, ctx: CalendarContext): boolean { + const cur = sideOfRow(row); + const bl = sideOfBaseline(baseline); + if (MASTER_COLUMN_UNITS.some((unit) => deviceChangedUnit(unit, cur, bl))) return true; + const [now, before] = [exdateEntries(cur), exdateEntries(bl)]; + if (now.size !== before.size || [...now].some((e) => !before.has(e))) return true; + return ctx.reminderOwner === 'device' && remindersDiffer(cur, bl); +} + +/** + * Applied once the pair's patch is accepted: the new row takes the identity + * and the old row goes. The old row's exception rows move to the new row with + * what they wait to upload (Etar copies only the exceptions it never synced; + * an app's copy of one the old row has goes). A copied exception takes the + * identity of its override. The shadow is the old one with the patch applied + * until the server's comes back. A row that still differs from its baseline + * (see `pairedBaseline`) stays dirty and uploads next. + */ +export function pairOps(deleted: LocalEvent, fresh: LocalEvent, kind: PairKind, patch: PatchObject, ctx: CalendarContext): OpGroup { const syncId = deleted.syncId!; const shadow: CalendarEventWire = applyPatch(deleted.shadow!, patch) ?? deleted.shadow!; const group = new GroupBuilder(syncId); assertRow(group, deleted, false, false); + for (const x of deleted.exceptions) assertRow(group, x, true, x.dirty || x.deleted); + assertExceptionCount(group, deleted.eventId, linkedExceptionCount(deleted)); 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); + assertExceptionCount(group, fresh.eventId, linkedExceptionCount(fresh)); + const kept = new Set(); + for (const x of deleted.exceptions) { + // A series turned single has no occurrences left. + if (kind.unrecur || !x.recurrenceId) { + deleteRow(group, x.eventId); + continue; + } + kept.add(x.recurrenceId); + const values: Row = { [Events.ORIGINAL_ID]: fresh.eventId, [Events.CALENDAR_ID]: fresh.calendarRowId }; + if (x.baseline) values[Events.SYNC_DATA4] = encodeBaseline({ ...x.baseline, cells: { ...x.baseline.cells, [Events.CALENDAR_ID]: fresh.calendarRowId } }); + group.write({ op: 'update', table: 'events', id: x.eventId, values, expectCount: 1 }); + } 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; + const baseline = pairedBaseline(deleted, fresh, kind, ctx); updateRow( group, { ...fresh, shadow: null }, @@ -126,18 +185,22 @@ export function pairOps(deleted: LocalEvent, fresh: LocalEvent, patch: PatchObje isException: false, cells, ...children(fresh), - baseline: makeBaseline(fresh.cells, MASTER_CELLS, children(fresh).attendees, children(fresh).reminders), + baseline, syncId, uid: typeof shadow.uid === 'string' ? shadow.uid : null, shadow, pending: null, - clearDirty: true, + clearDirty: !changedSince(fresh, baseline, ctx), hasParticipants: fresh.attendees.length > 0, }, ); const overrides = overridesOf(shadow); for (const x of fresh.exceptions) { const key = x.recurrenceId; + if (key && kept.has(key)) { + deleteRow(group, x.eventId); + continue; + } const values: Row = { [Events.ORIGINAL_SYNC_ID]: syncId }; if (key && overrides[key] && !isExcluded(overrides[key])) { Object.assign(values, { diff --git a/src/device-sync/calendar/planner.ts b/src/device-sync/calendar/planner.ts index 08a2ac15..e5051242 100644 --- a/src/device-sync/calendar/planner.ts +++ b/src/device-sync/calendar/planner.ts @@ -385,7 +385,7 @@ function planPairs( 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) }); + pairs.push({ deleted: d, fresh: f, actions: [action], ops: pairOps(d, f, kind, pair.patch, ctx) }); used.add(f.eventId); break; } diff --git a/src/device-sync/engine/calendar-sync.ts b/src/device-sync/engine/calendar-sync.ts index 1cb3d787..a4112297 100644 --- a/src/device-sync/engine/calendar-sync.ts +++ b/src/device-sync/engine/calendar-sync.ts @@ -492,6 +492,12 @@ export class CalendarSync extends ItemSync { if (ownId) this.markStale(acct, ownId); return []; } + if (this.eventKind.meta(moved).dirty) { + // Edits saved with the move, or made before it, are still to go up: the update phase + // sends them as the moved event's, and its accepted write brings the server's version. + this.env.report.stats.uploaded.updated++; + return []; + } const held: Held = { kind: this.eventKind, local: moved }; try { const work = this.acceptWork(acct, { held, meta: this.eventKind.meta(moved), fingerprint: '', actions: [] }, server, false);