fix: stop events from Google Calendar arriving as tentative #34

Google Calendar leaves STATUS empty on the events and exceptions it
creates, and CalendarProvider has no default for it. Device sync read
the empty value as 0, STATUS_TENTATIVE, so every event and every "this
event" edit made there uploaded as "tentative" and came back to the
phone that way; a copy made with "Copy to" also never paired with the
deleted original.

An empty STATUS now sets no status: a create leaves `status` out, so
the server keeps its default of confirmed, an override gets none of its
own, an edited event never uploads one, a merge takes the server's
without counting a conflict, and a pair ignores it. After the upload
the rows hold the server's status.

A new series from Etar's "this and following" also no longer uploads
the old series' exclusions from before its start, which Etar copies
into the new row's EXDATE.

Checked with planner tests for each path and a run through the engine
and the fake server.
This commit is contained in:
Linus Rath
2026-09-27 07:10:47 +02:00
parent 232e892328
commit 2870a59971
9 changed files with 150 additions and 7 deletions
@@ -17,6 +17,47 @@ function real(options: Parameters<typeof createHarness>[0] = {}): Harness {
type Overrides = Record<string, Record<string, unknown>>;
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', {
@@ -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<string, Record<string, unknown>>;
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';
@@ -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);
+7 -3
View File
@@ -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);
+2 -1
View File
@@ -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]) {
+3
View File
@@ -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;
+11
View File
@@ -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<string, readonly string[]> = {
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<string, Row> {
const out = new Map<string, Row>();
+3 -2
View File
@@ -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;
+12 -1
View File
@@ -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;