Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions src/helpers/database.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 15 additions & 7 deletions src/helpers/microsoftCalendarManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions test/helpers/calendarDatabase.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
34 changes: 32 additions & 2 deletions test/helpers/microsoftCalendarManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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");
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
Loading