diff --git a/CLAUDE.md b/CLAUDE.md index 01ba8b9f3..bacdf0480 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,7 +144,7 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. - Sync tokens pin the `timeMin`/`timeMax` window of the full sync that created them (incremental syncs never roll it forward), so tokens are discarded after 1 day to keep the 9-day lookahead covering the availability tool's 7-day horizon - **microsoftCalendarManager.js**: Microsoft Calendar sync via Graph API (OAuth via `microsoftCalendarOAuth.js`) - `calendarView/delta` incremental sync over a 14-day window; delta token discarded after 7 days (Graph delta links never roll their window forward) - - Delta can return recurring-series occurrences as bare stubs (no subject/attendees/meeting link); they're backfilled from their series master, one `GET /me/events/{id}` per series + - Delta can return recurring-series occurrences as bare stubs (no subject/attendees/meeting link); they're backfilled from their series master, one `GET /me/calendars/{calendarId}/events/{id}` per series (calendar-scoped: `/me/events/{id}` 404s for shared calendars). A failed backfill shortens the delta token TTL to 10 min so an early full sync retries instead of leaving untitled blocks - Full re-sync (410 or expired token) prunes stale events like Google - **appleCalendarManager.js**: Apple Calendar (EventKit) via the `macos-calendar-listener` Swift helper — macOS only, snapshot-push over stdout, no tokens ("connected" = `apple_calendars` has rows) - **calendarReminderScheduler.js**: Provider-agnostic meeting reminder scheduling over the shared `calendar_events` table (provider-scoped reset keys, so one provider's disconnect doesn't re-fire another's reminders) diff --git a/src/helpers/database.js b/src/helpers/database.js index 5d6c0877d..373c1f20b 100644 --- a/src/helpers/database.js +++ b/src/helpers/database.js @@ -546,6 +546,16 @@ class DatabaseManager { ) `); + // One-time reset (user_version 2): pre-fix builds stored recurring + // occurrences untitled when the series-master fetch failed, and delta + // never re-delivers them; a forced full sync re-fetches them fixed. + if (this.db.pragma("user_version", { simple: true }) < 2) { + this.db.exec( + "UPDATE microsoft_calendars SET sync_token = NULL, sync_token_expires_at = NULL" + ); + this.db.pragma("user_version = 2"); + } + this.db.exec(` CREATE TABLE IF NOT EXISTS calendar_events ( id TEXT PRIMARY KEY, diff --git a/src/helpers/microsoftCalendarManager.js b/src/helpers/microsoftCalendarManager.js index bbe31fb05..0a834c48e 100644 --- a/src/helpers/microsoftCalendarManager.js +++ b/src/helpers/microsoftCalendarManager.js @@ -16,6 +16,9 @@ const SERIES_MASTER_FIELDS = // after 7 days so coverage never drops below the app's 7-day lookahead. const DELTA_WINDOW_MS = 14 * 24 * 60 * 60 * 1000; const DELTA_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; +// A failed series-master backfill can't be retried via delta (unchanged +// occurrences are never re-delivered); a short TTL forces an early full sync. +const BACKFILL_RETRY_TTL_MS = 10 * 60 * 1000; const BUFFER_COVERAGE_MS = MAX_BUFFER_MINUTES * 60 * 1000; const LOOKBACK_SAFETY_MS = 24 * 60 * 60 * 1000; @@ -227,7 +230,10 @@ class MicrosoftCalendarManager { url = data["@odata.nextLink"] || null; } - const events = await this._backfillStrippedOccurrences(items, accountEmail); + const events = await this._backfillStrippedOccurrences(items, calendar); + if (events.some(isStrippedOccurrence)) { + tokenExpiresAt = Math.min(tokenExpiresAt, Date.now() + BACKFILL_RETRY_TTL_MS); + } const toUpsert = []; const contactsToUpsert = []; @@ -269,21 +275,23 @@ class MicrosoftCalendarManager { } // Merges each stripped occurrence with its series master (fetched once per - // series); the occurrence's own id/start/end win. A failed master fetch - // leaves its occurrences bare instead of failing the calendar's sync; - // _syncCalendar decides whether a bare stub may be written. - async _backfillStrippedOccurrences(items, accountEmail) { + // series, through its calendar — /me/events/{id} 404s for shared calendars); + // the occurrence's own id/start/end win. A failed master fetch leaves its + // occurrences bare instead of failing the calendar's sync; _syncCalendar + // decides whether a bare stub may be written. + async _backfillStrippedOccurrences(items, calendar) { const masterIds = new Set( items.filter(isStrippedOccurrence).map((item) => item.seriesMasterId) ); if (masterIds.size === 0) return items; + const calendarPath = encodeURIComponent(calendar.id); const masters = new Map(); for (const id of masterIds) { try { const master = await this._apiGet( - `/me/events/${encodeURIComponent(id)}?$select=${SERIES_MASTER_FIELDS}`, - accountEmail + `/me/calendars/${calendarPath}/events/${encodeURIComponent(id)}?$select=${SERIES_MASTER_FIELDS}`, + calendar.account_email ); masters.set(id, master); } catch (err) { diff --git a/test/helpers/calendarDatabase.test.js b/test/helpers/calendarDatabase.test.js index fd93615f1..89e490f08 100644 --- a/test/helpers/calendarDatabase.test.js +++ b/test/helpers/calendarDatabase.test.js @@ -234,6 +234,24 @@ test("availability range query treats date-only all-day events as local dates", db.db.close(); }); +test("reopening a pre-v2 database clears microsoft sync tokens once", (t) => { + const db = createDb(t); + if (!db) return; + insertCalendar(db, "microsoft", "ms-calendar"); + db.updateMicrosoftCalendarSyncToken("ms-calendar", "delta-link", Date.now() + 1000000); + db.db.pragma("user_version = 1"); + db.db.close(); + + const reopened = new DatabaseManager(); + const calendar = reopened.db + .prepare("SELECT * FROM microsoft_calendars WHERE id = 'ms-calendar'") + .get(); + assert.equal(calendar.sync_token, null); + assert.equal(calendar.sync_token_expires_at, null); + assert.equal(reopened.db.pragma("user_version", { simple: true }), 2); + reopened.db.close(); +}); + test("google sync token persists alongside its expiry", (t) => { const db = createDb(t); if (!db) return; diff --git a/test/helpers/microsoftCalendarManager.test.js b/test/helpers/microsoftCalendarManager.test.js index ceb00f800..a04946f2d 100644 --- a/test/helpers/microsoftCalendarManager.test.js +++ b/test/helpers/microsoftCalendarManager.test.js @@ -164,7 +164,11 @@ test("_syncCalendar backfills stripped recurring occurrences from their series m const MicrosoftCalendarManager = loadManagerModule(); const upserted = []; const contacts = []; - const manager = createManager(MicrosoftCalendarManager, upserted, contacts); + const tokenWrites = []; + const manager = createManager(MicrosoftCalendarManager, upserted, contacts, { + updateMicrosoftCalendarSyncToken: (id, token, expiresAt) => + tokenWrites.push({ id, token, expiresAt }), + }); const masterFetches = []; manager._apiGet = async (url) => { @@ -207,7 +211,7 @@ test("_syncCalendar backfills stripped recurring occurrences from their series m await manager._syncCalendar({ id: "cal-1", account_email: "me@example.com" }); assert.equal(masterFetches.length, 1); - assert.match(masterFetches[0], /^\/me\/events\/master-1\?\$select=/); + assert.match(masterFetches[0], /^\/me\/calendars\/cal-1\/events\/master-1\?\$select=/); const occurrence = upserted.find((event) => event.id === "occ-1"); assert.equal(occurrence.summary, "Standup"); @@ -218,6 +222,7 @@ test("_syncCalendar backfills stripped recurring occurrences from their series m assert.equal(upserted.find((event) => event.id === "occ-2").summary, "Standup"); assert.equal(upserted.find((event) => event.id === "evt-1").summary, "One-off"); assert.ok(contacts.some((contact) => contact.email === "me@example.com")); + assert.ok(tokenWrites[0].expiresAt > Date.now() + 6 * 24 * 60 * 60 * 1000); }); test("_syncCalendar inserts a never-seen stripped occurrence bare when the series master fetch fails", async () => { @@ -240,6 +245,31 @@ test("_syncCalendar inserts a never-seen stripped occurrence bare when the serie assert.equal(upserted[0].start_time, "2026-07-20T09:25:00Z"); }); +test("_syncCalendar shortens the delta token TTL when a master fetch fails", async () => { + const MicrosoftCalendarManager = loadManagerModule(); + const upserted = []; + const tokenWrites = []; + const manager = createManager(MicrosoftCalendarManager, upserted, [], { + updateMicrosoftCalendarSyncToken: (id, token, expiresAt) => + tokenWrites.push({ id, token, expiresAt }), + }); + + manager._apiGet = async (url) => { + if (url.includes("/calendarView/delta")) { + return { "@odata.deltaLink": "delta-link", value: [STRIPPED_OCCURRENCE] }; + } + throw new Error("master gone"); + }; + + await manager._syncCalendar({ id: "cal-1", account_email: "me@example.com" }); + + assert.equal(tokenWrites.length, 1); + assert.ok( + tokenWrites[0].expiresAt <= Date.now() + 10 * 60 * 1000, + `expected a shortened TTL, got expiry ${tokenWrites[0].expiresAt - Date.now()}ms out` + ); +}); + // A bare stub has attendees_count 0 and no join link, which the reminder // scheduler treats as a time block — it must not overwrite a full row. test("_syncCalendar keeps the stored row when a stripped occurrence's master fetch fails", async () => {