diff --git a/src/seqta/utils/calendarSync/eventFingerprint.ts b/src/seqta/utils/calendarSync/eventFingerprint.ts new file mode 100644 index 00000000..bbede560 --- /dev/null +++ b/src/seqta/utils/calendarSync/eventFingerprint.ts @@ -0,0 +1,42 @@ +/** Stable content fingerprint for skip-unchanged calendar sync. */ + +export type FingerprintableEvent = { + summary: string; + location?: string; + description?: string; + startDateTime: string; + endDateTime: string; + timeZone: string; +}; + +export function eventFingerprint(event: FingerprintableEvent): string { + return [ + event.summary.trim(), + (event.location ?? "").trim(), + (event.description ?? "").trim(), + event.startDateTime.trim(), + event.endDateTime.trim(), + event.timeZone.trim(), + ].join("\n"); +} + +/** Parse `Key: {seqtaKey}` from Outlook event body text. */ +export function parseOutlookSeqtaKey(bodyContent: string | undefined | null): string | undefined { + if (!bodyContent) return undefined; + const match = bodyContent.match(/^Key:\s*(.+)$/m); + const key = match?.[1]?.trim(); + return key && key.length > 0 ? key : undefined; +} + +export function outlookDescriptionWithKey( + description: string | undefined, + seqtaKey: string, +): string { + const base = (description ?? "Synced by BetterSEQTA+").trim(); + const withoutKey = base + .split("\n") + .filter((line) => !/^Key:\s*/.test(line)) + .join("\n") + .trim(); + return `${withoutKey}\nKey: ${seqtaKey}`; +} diff --git a/src/seqta/utils/calendarSync/eventMap.ts b/src/seqta/utils/calendarSync/eventMap.ts index b83415ae..617703b8 100644 --- a/src/seqta/utils/calendarSync/eventMap.ts +++ b/src/seqta/utils/calendarSync/eventMap.ts @@ -3,6 +3,8 @@ import browser from "webextension-polyfill"; export interface EventMapEntry { id: string; date: string; + /** Last-synced content fingerprint; used to skip unchanged events. */ + fingerprint?: string; } export type EventMapRecord = Record; @@ -17,11 +19,21 @@ export function normalizeEventMapEntry( if (value == null) return undefined; if (typeof value === "string") return { id: value, date: "" }; if (typeof value.id === "string" && value.id.length > 0) { - return { id: value.id, date: value.date ?? "" }; + return { + id: value.id, + date: value.date ?? "", + fingerprint: value.fingerprint, + }; } return undefined; } +export function getStoredFingerprint( + value: string | EventMapEntry | undefined, +): string | undefined { + return normalizeEventMapEntry(value)?.fingerprint; +} + export function getStoredEventId( value: string | EventMapEntry | undefined, ): string | undefined { diff --git a/src/seqta/utils/calendarSync/lessonSyncShared.test.ts b/src/seqta/utils/calendarSync/lessonSyncShared.test.ts index 49dc997a..7b86f0c7 100644 --- a/src/seqta/utils/calendarSync/lessonSyncShared.test.ts +++ b/src/seqta/utils/calendarSync/lessonSyncShared.test.ts @@ -4,8 +4,21 @@ jest.mock("@/utils/verboseLog", () => ({ verboseLog: jest.fn(), })); -import { reportSyncProgress } from "./lessonSyncShared"; +import { + eventFingerprint, + outlookDescriptionWithKey, + parseOutlookSeqtaKey, +} from "./eventFingerprint"; +import { + buildLessonSyncResult, + entriesToPrune, + formatLessonSyncResultMessage, + mergeRemoteEventsIntoMap, + reportSyncProgress, + upsertLessonEvents, +} from "./lessonSyncShared"; import type { GoogleCalendarSyncProgress } from "@/seqta/utils/googleCalendar/types"; +import { syncWindowRange } from "@/seqta/utils/googleCalendar/syncDateRange"; describe("reportSyncProgress", () => { it("calls onProgress when provided", () => { @@ -24,3 +37,189 @@ describe("reportSyncProgress", () => { expect(onProgress).toHaveBeenCalledWith(progress); }); }); + +describe("eventFingerprint", () => { + it("is stable for equivalent event content", () => { + const base = { + summary: "Math", + location: "MA1", + description: "Synced by BetterSEQTA+", + startDateTime: "2026-07-13T09:00:00", + endDateTime: "2026-07-13T10:00:00", + timeZone: "Australia/Adelaide", + }; + expect(eventFingerprint(base)).toBe(eventFingerprint({ ...base })); + expect(eventFingerprint(base)).not.toBe( + eventFingerprint({ ...base, summary: "English" }), + ); + }); +}); + +describe("outlook Key helpers", () => { + it("embeds and parses Key lines", () => { + const withKey = outlookDescriptionWithKey("Synced by BetterSEQTA+\nTeacher: A", "origin:cal:1"); + expect(withKey).toContain("Key: origin:cal:1"); + expect(parseOutlookSeqtaKey(withKey)).toBe("origin:cal:1"); + expect(outlookDescriptionWithKey(withKey, "origin:cal:2")).toContain("Key: origin:cal:2"); + expect(outlookDescriptionWithKey(withKey, "origin:cal:2").match(/^Key:/gm)).toHaveLength(1); + }); +}); + +describe("entriesToPrune", () => { + const origin = "https://school.seqta.com.au"; + const weeksAhead = 12; + const syncDate = syncWindowRange(weeksAhead).from; + const mapKey = `${origin}::${origin}:cal:1`; + const cancelledKey = `${origin}::${origin}:cal:cancelled`; + + it("returns nothing for incremental mode", () => { + expect( + entriesToPrune( + { [mapKey]: { id: "evt-1", date: syncDate } }, + origin, + "incremental", + weeksAhead, + new Set([mapKey]), + ), + ).toEqual([]); + }); + + it("prunes cancelled lessons still inside the sync window", () => { + const pruned = entriesToPrune( + { + [mapKey]: { id: "evt-1", date: syncDate }, + [cancelledKey]: { id: "evt-cancelled", date: syncDate }, + }, + origin, + "full", + weeksAhead, + new Set([mapKey]), + ); + expect(pruned).toEqual([[cancelledKey, "evt-cancelled"]]); + }); + + it("prunes events whose date is outside the sync window", () => { + const staleKey = `${origin}::${origin}:cal:old`; + const pruned = entriesToPrune( + { [staleKey]: { id: "evt-old", date: "2020-01-06" } }, + origin, + "full", + weeksAhead, + new Set(), + ); + expect(pruned).toEqual([[staleKey, "evt-old"]]); + }); +}); + +describe("mergeRemoteEventsIntoMap", () => { + const origin = "https://school.seqta.com.au"; + const mapKey = (o: string, seqtaKey: string) => `${o}::${seqtaKey}`; + + it("recovers remote IDs for the current origin only", () => { + const eventMap: Record = {}; + mergeRemoteEventsIntoMap( + eventMap, + origin, + [ + { + seqtaKey: `${origin}:cal:1`, + id: "remote-1", + date: "2026-07-13", + fingerprint: "fp-1", + }, + { + seqtaKey: "https://other.seqta.com.au:cal:2", + id: "remote-other", + date: "2026-07-13", + fingerprint: "fp-other", + }, + ], + mapKey, + ); + + expect(eventMap[`${origin}::${origin}:cal:1`]).toMatchObject({ + id: "remote-1", + fingerprint: "fp-1", + }); + expect(eventMap[`${origin}::https://other.seqta.com.au:cal:2`]).toBeUndefined(); + }); + + it("keeps an existing local fingerprint when reconciling", () => { + const key = `${origin}::${origin}:cal:1`; + const eventMap = { + [key]: { id: "old-id", date: "2026-07-13", fingerprint: "local-fp" }, + }; + mergeRemoteEventsIntoMap( + eventMap, + origin, + [ + { + seqtaKey: `${origin}:cal:1`, + id: "remote-1", + date: "2026-07-13", + fingerprint: "remote-fp", + }, + ], + mapKey, + ); + expect(eventMap[key]).toMatchObject({ id: "remote-1", fingerprint: "local-fp" }); + }); +}); + +describe("formatLessonSyncResultMessage", () => { + it("includes unchanged counts", () => { + expect( + formatLessonSyncResultMessage( + { success: true, created: 1, updated: 2, deleted: 0, skipped: 3 }, + "Google Calendar", + ), + ).toBe("Google Calendar updated (1 new, 2 updated, 3 unchanged)."); + }); + + it("reports up to date when nothing changed", () => { + expect( + formatLessonSyncResultMessage( + buildLessonSyncResult(0, 0, 0, 0, 0, Date.now()), + "Outlook Calendar", + ), + ).toBe("Outlook Calendar is up to date."); + }); +}); + +describe("upsertLessonEvents skip unchanged", () => { + it("skips API writes when fingerprint matches", async () => { + const origin = "https://school.seqta.com.au"; + const event = { + seqtaKey: `${origin}:cal:1`, + summary: "Math", + description: "Synced by BetterSEQTA+", + startDateTime: "2026-07-13T09:00:00", + endDateTime: "2026-07-13T10:00:00", + timeZone: "UTC", + }; + const fp = eventFingerprint(event); + const key = `${origin}::${event.seqtaKey}`; + const eventMap = { + [key]: { id: "existing", date: "2026-07-13", fingerprint: fp }, + }; + const upsert = jest.fn(async () => "existing"); + + const result = await upsertLessonEvents({ + events: [event], + eventMap, + origin, + staleEntryCount: 0, + totalSteps: 1, + lastSyncAt: Date.now(), + initialFailed: 0, + getAccessToken: async () => "token", + mapKey: (o, k) => `${o}::${k}`, + upsert, + writeState: async () => undefined, + logLabel: "Test", + }); + + expect(upsert).not.toHaveBeenCalled(); + expect(result).toMatchObject({ created: 0, updated: 0, skipped: 1, failed: 0 }); + }); +}); diff --git a/src/seqta/utils/calendarSync/lessonSyncShared.ts b/src/seqta/utils/calendarSync/lessonSyncShared.ts index 0dcf2c01..d010a371 100644 --- a/src/seqta/utils/calendarSync/lessonSyncShared.ts +++ b/src/seqta/utils/calendarSync/lessonSyncShared.ts @@ -1,13 +1,17 @@ import { verboseLog } from "@/utils/verboseLog"; import { getStoredEventId, + getStoredFingerprint, lessonDateFromSeqtaKey, normalizeEventMapEntry, type EventMapRecord, } from "@/seqta/utils/calendarSync/eventMap"; +import { eventFingerprint } from "@/seqta/utils/calendarSync/eventFingerprint"; +import type { RemoteSyncedEvent } from "@/seqta/utils/calendarSync/remoteEvents"; import { isDateInRange, syncWindowRange, + type SyncDateRange, } from "@/seqta/utils/googleCalendar/syncDateRange"; import type { GoogleCalendarDeleteResult, @@ -20,7 +24,12 @@ export const EVENT_MAP_PERSIST_EVERY = 10; export type MappedLessonEvent = { seqtaKey: string; + summary: string; + location?: string; + description?: string; startDateTime: string; + endDateTime: string; + timeZone: string; }; export function reportSyncProgress( @@ -65,15 +74,49 @@ export function entriesToPrune( if (!mapKey.startsWith(prefix)) continue; const entry = normalizeEventMapEntry(raw); if (!entry) continue; - const stale = entry.date - ? !isDateInRange(entry.date, window) - : !currentMapKeys.has(mapKey); - if (stale) entries.push([mapKey, entry.id]); + const missingFromTimetable = !currentMapKeys.has(mapKey); + const outsideWindow = entry.date ? !isDateInRange(entry.date, window) : false; + if (missingFromTimetable || outsideWindow) { + entries.push([mapKey, entry.id]); + } } return entries; } +/** Merge remote BS+ events into the local eventMap (origin-scoped keys). */ +export function mergeRemoteEventsIntoMap( + eventMap: EventMapRecord, + origin: string, + remoteEvents: RemoteSyncedEvent[], + mapKey: (origin: string, seqtaKey: string) => string, +): void { + for (const remote of remoteEvents) { + if (!remote.seqtaKey.startsWith(origin)) continue; + const key = mapKey(origin, remote.seqtaKey); + const existing = normalizeEventMapEntry(eventMap[key]); + eventMap[key] = { + id: remote.id, + date: remote.date || existing?.date || lessonDateFromSeqtaKey(remote.seqtaKey) || "", + // Prefer last-written local fingerprint so skip stays stable across API format quirks. + fingerprint: existing?.fingerprint ?? remote.fingerprint, + }; + } +} + +export function reconcileRangeForMode( + mode: "full" | "incremental", + weeksAhead: number, + ranges: { + syncWindowRange: (weeks: number) => SyncDateRange; + trailingWeekRange: (weeks: number) => SyncDateRange; + }, +): SyncDateRange { + return mode === "incremental" + ? ranges.trailingWeekRange(weeksAhead) + : ranges.syncWindowRange(weeksAhead); +} + export function notConfiguredSyncResult(error: string): GoogleCalendarSyncResult { return { success: false, configured: false, error }; } @@ -98,10 +141,12 @@ export function formatLessonSyncResultMessage( const created = result.created ?? 0; const updated = result.updated ?? 0; const deleted = result.deleted ?? 0; + const skipped = result.skipped ?? 0; const parts: string[] = []; if (created > 0) parts.push(`${created} new`); if (updated > 0) parts.push(`${updated} updated`); if (deleted > 0) parts.push(`${deleted} removed`); + if (skipped > 0) parts.push(`${skipped} unchanged`); if (parts.length === 0) return `${calendarLabel} is up to date.`; return `${calendarLabel} updated (${parts.join(", ")}).`; } @@ -187,6 +232,7 @@ export function buildLessonSyncResult( created: number, updated: number, deleted: number, + skipped: number, failed: number, lastSyncAt: number, ): GoogleCalendarSyncResult { @@ -197,7 +243,7 @@ export function buildLessonSyncResult( created, updated, deleted, - skipped: 0, + skipped, failed, lastSyncAt, error: @@ -234,7 +280,13 @@ type UpsertLessonEventsParams = { export async function upsertLessonEvents( params: UpsertLessonEventsParams, -): Promise<{ created: number; updated: number; failed: number; accessToken: string }> { +): Promise<{ + created: number; + updated: number; + skipped: number; + failed: number; + accessToken: string; +}> { const { events, eventMap, @@ -254,16 +306,29 @@ export async function upsertLessonEvents( let accessToken = await getAccessToken(); let created = 0; let updated = 0; + let skipped = 0; let failed = initialFailed; for (let i = 0; i < events.length; i++) { const event = events[i]; const key = mapKey(origin, event.seqtaKey); const existingId = getStoredEventId(eventMap[key]); + const desiredFingerprint = eventFingerprint(event); const progressCurrent = staleEntryCount + i + 1; const progressMessage = `Syncing events (${i + 1}/${events.length})…`; try { + if (existingId && getStoredFingerprint(eventMap[key]) === desiredFingerprint) { + skipped += 1; + reportSyncProgress(onProgress, { + phase: "upserting", + current: progressCurrent, + total: totalSteps, + message: progressMessage, + }); + continue; + } + const remoteId = await upsert(accessToken, existingId, event, async () => { accessToken = await getAccessToken(); return accessToken; @@ -273,6 +338,7 @@ export async function upsertLessonEvents( eventMap[key] = { id: remoteId, date: lessonDateForEvent(event.startDateTime, event.seqtaKey), + fingerprint: desiredFingerprint, }; if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) { @@ -291,5 +357,5 @@ export async function upsertLessonEvents( }); } - return { created, updated, failed, accessToken }; + return { created, updated, skipped, failed, accessToken }; } diff --git a/src/seqta/utils/calendarSync/remoteEvents.ts b/src/seqta/utils/calendarSync/remoteEvents.ts index ba28112d..7be3a39c 100644 --- a/src/seqta/utils/calendarSync/remoteEvents.ts +++ b/src/seqta/utils/calendarSync/remoteEvents.ts @@ -1,5 +1,20 @@ -import { GOOGLE_CALENDAR_API } from "@/config/googleCalendar"; -import { OUTLOOK_GRAPH_API } from "@/config/outlookCalendar"; +import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP, GOOGLE_CALENDAR_API } from "@/config/googleCalendar"; +import { + BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY, + OUTLOOK_GRAPH_API, +} from "@/config/outlookCalendar"; +import { + eventFingerprint, + parseOutlookSeqtaKey, +} from "@/seqta/utils/calendarSync/eventFingerprint"; +import type { SyncDateRange } from "@/seqta/utils/googleCalendar/syncDateRange"; + +export type RemoteSyncedEvent = { + seqtaKey: string; + id: string; + fingerprint: string; + date: string; +}; async function authorizedFetch( accessToken: string, @@ -138,3 +153,190 @@ export function deleteOutlookCalendarEvent( refreshAccessToken, ); } + +function toRfc3339Start(date: string): string { + return `${date}T00:00:00Z`; +} + +function toRfc3339EndExclusive(date: string): string { + const d = new Date(`${date}T12:00:00Z`); + d.setUTCDate(d.getUTCDate() + 1); + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${day}T00:00:00Z`; +} + +function dateFromDateTime(value: string | undefined): string { + if (!value) return ""; + return value.slice(0, 10); +} + +type GoogleListItem = { + id?: string; + summary?: string; + location?: string; + description?: string; + start?: { dateTime?: string; date?: string; timeZone?: string }; + end?: { dateTime?: string; date?: string; timeZone?: string }; + extendedProperties?: { private?: Record }; +}; + +function googleItemToRemote(item: GoogleListItem): RemoteSyncedEvent | null { + const seqtaKey = item.extendedProperties?.private?.[BSPLUS_GOOGLE_CALENDAR_EVENT_PROP]; + if (!item.id || !seqtaKey) return null; + const startDateTime = item.start?.dateTime ?? (item.start?.date ? `${item.start.date}T00:00:00` : ""); + const endDateTime = item.end?.dateTime ?? (item.end?.date ? `${item.end.date}T00:00:00` : ""); + const timeZone = item.start?.timeZone ?? item.end?.timeZone ?? "UTC"; + return { + seqtaKey, + id: item.id, + date: dateFromDateTime(startDateTime) || item.start?.date || "", + fingerprint: eventFingerprint({ + summary: item.summary ?? "", + location: item.location, + description: item.description, + startDateTime, + endDateTime, + timeZone, + }), + }; +} + +export async function listGoogleSyncedEvents( + accessToken: string, + calendarId: string, + range: SyncDateRange, + refreshAccessToken?: () => Promise, +): Promise { + const encodedCalendar = encodeURIComponent(calendarId); + const out: RemoteSyncedEvent[] = []; + let pageToken: string | undefined; + + do { + const params = new URLSearchParams({ + singleEvents: "true", + orderBy: "startTime", + maxResults: "2500", + timeMin: toRfc3339Start(range.from), + timeMax: toRfc3339EndExclusive(range.until), + fields: + "nextPageToken,items(id,summary,location,description,start,end,extendedProperties)", + }); + if (pageToken) params.set("pageToken", pageToken); + + const res = await authorizedFetch( + accessToken, + `${GOOGLE_CALENDAR_API}/calendars/${encodedCalendar}/events?${params}`, + { method: "GET" }, + refreshAccessToken, + ); + const json = (await res.json().catch(() => ({}))) as { + items?: GoogleListItem[]; + nextPageToken?: string; + error?: { message?: string }; + }; + if (!res.ok) { + throw new Error(json?.error?.message ?? `Google Calendar list failed (${res.status})`); + } + + for (const item of json.items ?? []) { + const mapped = googleItemToRemote(item); + if (mapped) out.push(mapped); + } + pageToken = json.nextPageToken; + } while (pageToken); + + return out; +} + +type OutlookListItem = { + id?: string; + subject?: string; + body?: { content?: string; contentType?: string }; + location?: { displayName?: string }; + start?: { dateTime?: string; timeZone?: string }; + end?: { dateTime?: string; timeZone?: string }; + categories?: string[]; +}; + +function outlookItemToRemote(item: OutlookListItem): RemoteSyncedEvent | null { + if (!item.id) return null; + const categories = item.categories ?? []; + if (!categories.includes(BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY)) return null; + const bodyContent = item.body?.content ?? ""; + const seqtaKey = parseOutlookSeqtaKey(bodyContent); + if (!seqtaKey) return null; + + const startDateTime = (item.start?.dateTime ?? "").replace(/\.\d+$/, ""); + const endDateTime = (item.end?.dateTime ?? "").replace(/\.\d+$/, ""); + const timeZone = item.start?.timeZone ?? item.end?.timeZone ?? "UTC"; + + return { + seqtaKey, + id: item.id, + date: dateFromDateTime(startDateTime), + fingerprint: eventFingerprint({ + summary: item.subject ?? "", + location: item.location?.displayName, + description: outlookDescriptionForFingerprint(bodyContent), + startDateTime, + endDateTime, + timeZone, + }), + }; +} + +/** Strip Outlook Key line so fingerprint matches local mapped event description. */ +function outlookDescriptionForFingerprint(bodyContent: string): string { + return bodyContent + .split("\n") + .filter((line) => !/^Key:\s*/.test(line)) + .join("\n") + .trim(); +} + +export async function listOutlookSyncedEvents( + accessToken: string, + range: SyncDateRange, + refreshAccessToken?: () => Promise, +): Promise { + const out: RemoteSyncedEvent[] = []; + const params = new URLSearchParams({ + startDateTime: toRfc3339Start(range.from), + endDateTime: toRfc3339EndExclusive(range.until), + $top: "100", + $select: "id,subject,body,location,start,end,categories", + }); + + let nextUrl: string | undefined = + `${OUTLOOK_GRAPH_API}/me/calendarView?${params.toString()}`; + + while (nextUrl) { + const res = await authorizedFetch( + accessToken, + nextUrl, + { + method: "GET", + headers: { Prefer: 'outlook.body-content-type="text"' }, + }, + refreshAccessToken, + ); + const json = (await res.json().catch(() => ({}))) as { + value?: OutlookListItem[]; + "@odata.nextLink"?: string; + error?: { message?: string }; + }; + if (!res.ok) { + throw new Error(json?.error?.message ?? `Outlook Calendar list failed (${res.status})`); + } + + for (const item of json.value ?? []) { + const mapped = outlookItemToRemote(item); + if (mapped) out.push(mapped); + } + nextUrl = json["@odata.nextLink"]; + } + + return out; +} diff --git a/src/seqta/utils/calendarSync/syncEngine.ts b/src/seqta/utils/calendarSync/syncEngine.ts index 1a55bb29..0dd140ce 100644 --- a/src/seqta/utils/calendarSync/syncEngine.ts +++ b/src/seqta/utils/calendarSync/syncEngine.ts @@ -9,13 +9,19 @@ import { deleteTrackedLessonEvents, emptyLessonsSyncResult, entriesToPrune, + mergeRemoteEventsIntoMap, notConfiguredSyncResult, notConnectedSyncResult, originEventMapEntries, + reconcileRangeForMode, reportSyncProgress, upsertLessonEvents, } from "@/seqta/utils/calendarSync/lessonSyncShared"; -import { googleApiEventBody, mapLessonsToGoogleEvents, outlookGraphEventBody } from "@/seqta/utils/googleCalendar/eventMapper"; +import { + googleApiEventBody, + mapLessonsToGoogleEvents, + outlookGraphEventBody, +} from "@/seqta/utils/googleCalendar/eventMapper"; import { readGoogleCalendarState, writeGoogleCalendarState, @@ -30,14 +36,23 @@ import type { import { deleteGoogleCalendarEvent, deleteOutlookCalendarEvent, + listGoogleSyncedEvents, + listOutlookSyncedEvents, upsertGoogleCalendarEvent, upsertOutlookCalendarEvent, + type RemoteSyncedEvent, } from "@/seqta/utils/calendarSync/remoteEvents"; import { ensureGoogleAppCalendar } from "@/seqta/utils/googleCalendar/calendarProvisioning"; +import { + syncWindowRange, + trailingWeekRange, + type SyncDateRange, +} from "@/seqta/utils/googleCalendar/syncDateRange"; import { readOutlookCalendarState, writeOutlookCalendarState, } from "@/seqta/utils/outlookCalendar/storage"; +import { verboseLog } from "@/utils/verboseLog"; type CalendarStoredState = { refreshToken?: string; @@ -67,6 +82,11 @@ export type CalendarLessonSyncProvider = { body: Record, refreshAccessToken: () => Promise, ) => Promise; + listSyncedEvents: ( + accessToken: string, + range: SyncDateRange, + refreshAccessToken: () => Promise, + ) => Promise; toApiBody: (event: GoogleCalendarEventInput) => Record; }; @@ -101,6 +121,13 @@ export const googleLessonSyncProvider: CalendarLessonSyncProvider = { body, refreshAccessToken, ), + listSyncedEvents: async (accessToken, range, refreshAccessToken) => + listGoogleSyncedEvents( + accessToken, + await getOrProvisionGoogleCalendarId(accessToken), + range, + refreshAccessToken, + ), toApiBody: googleApiEventBody, }; @@ -115,6 +142,8 @@ export const outlookLessonSyncProvider: CalendarLessonSyncProvider = { deleteOutlookCalendarEvent(accessToken, eventId, refreshAccessToken), upsertEvent: (accessToken, existingId, body, refreshAccessToken) => upsertOutlookCalendarEvent(accessToken, existingId, body, refreshAccessToken), + listSyncedEvents: (accessToken, range, refreshAccessToken) => + listOutlookSyncedEvents(accessToken, range, refreshAccessToken), toApiBody: outlookGraphEventBody, }; @@ -151,6 +180,28 @@ export async function syncLessonsToCalendar( }); const eventMap = { ...(state.eventMap ?? {}) }; + let accessToken = await getAccessToken(); + const refreshAccessToken = async () => { + accessToken = await getAccessToken(); + return accessToken; + }; + + const reconcileRange = reconcileRangeForMode(mode, weeksAhead, { + syncWindowRange, + trailingWeekRange, + }); + + try { + const remoteEvents = await provider.listSyncedEvents( + accessToken, + reconcileRange, + refreshAccessToken, + ); + mergeRemoteEventsIntoMap(eventMap, request.origin, remoteEvents, eventMapKey); + } catch (err) { + verboseLog(`[BetterSEQTA+] ${provider.label} remote event list failed:`, err); + } + const currentMapKeys = new Set(events.map((event) => eventMapKey(request.origin, event.seqtaKey))); const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys); const totalSteps = staleEntries.length + events.length; @@ -179,13 +230,8 @@ export async function syncLessonsToCalendar( initialFailed: staleResult.failed, getAccessToken, mapKey: eventMapKey, - upsert: (accessToken, existingId, event, refreshAccessToken) => - provider.upsertEvent( - accessToken, - existingId, - provider.toApiBody(event), - refreshAccessToken, - ), + upsert: (token, existingId, event, refresh) => + provider.upsertEvent(token, existingId, provider.toApiBody(event), refresh), writeState: provider.writeState, onProgress: options.onProgress, logLabel: provider.label, @@ -210,6 +256,7 @@ export async function syncLessonsToCalendar( upsertResult.created, upsertResult.updated, staleResult.deleted, + upsertResult.skipped, upsertResult.failed, lastSyncAt, ); diff --git a/src/seqta/utils/googleCalendar/eventMapper.test.ts b/src/seqta/utils/googleCalendar/eventMapper.test.ts index f6e2ef94..7db17c67 100644 --- a/src/seqta/utils/googleCalendar/eventMapper.test.ts +++ b/src/seqta/utils/googleCalendar/eventMapper.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@jest/globals"; import { lessonToGoogleEvent, mapLessonsToGoogleEvents, + outlookGraphEventBody, seqtaLessonKey, shouldSyncLesson, } from "./eventMapper"; @@ -62,3 +63,14 @@ describe("mapLessonsToGoogleEvents", () => { expect(events).toHaveLength(1); }); }); + +describe("outlookGraphEventBody", () => { + it("embeds Key: seqtaKey in the body for remote reconcile", () => { + const event = lessonToGoogleEvent(ORIGIN, baseLesson, "Australia/Perth"); + expect(event).not.toBeNull(); + const body = outlookGraphEventBody(event!); + const content = (body.body as { content: string }).content; + expect(content).toContain(`Key: ${ORIGIN}:cal:12345`); + expect(content).toContain("Synced by BetterSEQTA+"); + }); +}); diff --git a/src/seqta/utils/googleCalendar/eventMapper.ts b/src/seqta/utils/googleCalendar/eventMapper.ts index 03b4d4f2..755110b8 100644 --- a/src/seqta/utils/googleCalendar/eventMapper.ts +++ b/src/seqta/utils/googleCalendar/eventMapper.ts @@ -1,5 +1,6 @@ import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar"; import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar"; +import { outlookDescriptionWithKey } from "@/seqta/utils/calendarSync/eventFingerprint"; import type { GoogleCalendarEventInput, SeqtaTimetableLesson } from "./types"; const SKIP_TYPES = new Set(["note", "holiday", "assembly-note"]); @@ -100,7 +101,7 @@ export function outlookGraphEventBody(event: GoogleCalendarEventInput): Record ({ isGoogleCalendarConfigured: jest.fn(() => true), + BSPLUS_GOOGLE_CALENDAR_EVENT_PROP: "bsplusSeqtaKey", + GOOGLE_CALENDAR_API: "https://www.googleapis.com/calendar/v3", })); jest.mock("@/utils/verboseLog", () => ({ @@ -27,14 +29,21 @@ jest.mock("@/seqta/utils/calendarSync/settings", () => ({ jest.mock("@/seqta/utils/calendarSync/remoteEvents", () => ({ upsertGoogleCalendarEvent: jest.fn(), deleteGoogleCalendarEvent: jest.fn(), + listGoogleSyncedEvents: jest.fn(async () => []), })); import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; import { deleteGoogleCalendarEvent, + listGoogleSyncedEvents, upsertGoogleCalendarEvent, } from "@/seqta/utils/calendarSync/remoteEvents"; -import { deleteSyncedEventsFromGoogleCalendar, syncLessonsToGoogleCalendar } from "@/seqta/utils/calendarSync/syncEngine"; +import { + deleteSyncedEventsFromGoogleCalendar, + syncLessonsToGoogleCalendar, +} from "@/seqta/utils/calendarSync/syncEngine"; +import { eventFingerprint } from "@/seqta/utils/calendarSync/eventFingerprint"; +import { lessonToGoogleEvent } from "@/seqta/utils/googleCalendar/eventMapper"; import { syncWindowRange } from "@/seqta/utils/googleCalendar/syncDateRange"; @@ -67,6 +76,7 @@ describe("syncLessonsToGoogleCalendar", () => { }); jest.mocked(upsertGoogleCalendarEvent).mockResolvedValue("google-existing"); jest.mocked(deleteGoogleCalendarEvent).mockResolvedValue(undefined); + jest.mocked(listGoogleSyncedEvents).mockResolvedValue([]); }); it("updates existing events and removes stale tracked events on full sync", async () => { @@ -75,6 +85,7 @@ describe("syncLessonsToGoogleCalendar", () => { getAccessToken, ); + expect(listGoogleSyncedEvents).toHaveBeenCalled(); expect(deleteGoogleCalendarEvent).toHaveBeenCalled(); expect(upsertGoogleCalendarEvent).toHaveBeenCalledTimes(1); expect(result).toMatchObject({ @@ -107,6 +118,64 @@ describe("syncLessonsToGoogleCalendar", () => { }); }); + it("recovers remote IDs when the local map is empty and skips unchanged", async () => { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const mapped = lessonToGoogleEvent(ORIGIN, baseLesson, timeZone); + expect(mapped).not.toBeNull(); + const fp = eventFingerprint(mapped!); + + jest.mocked(readGoogleCalendarState).mockResolvedValue({ + refreshToken: "refresh", + calendarId: "app-calendar-id", + eventMap: {}, + }); + jest.mocked(listGoogleSyncedEvents).mockResolvedValue([ + { + seqtaKey: `${ORIGIN}:cal:12345`, + id: "recovered-id", + date: syncDate, + fingerprint: fp, + }, + ]); + + const result = await syncLessonsToGoogleCalendar( + { origin: ORIGIN, lessons: [baseLesson], mode: "full" }, + getAccessToken, + ); + + expect(upsertGoogleCalendarEvent).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + success: true, + created: 0, + updated: 0, + skipped: 1, + }); + }); + + it("deletes cancelled lessons that remain inside the sync window", async () => { + jest.mocked(readGoogleCalendarState).mockResolvedValue({ + refreshToken: "refresh", + calendarId: "app-calendar-id", + eventMap: { + [`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "keep", date: syncDate }, + [`${ORIGIN}::${ORIGIN}:cal:cancelled`]: { id: "gone", date: syncDate }, + }, + }); + + const result = await syncLessonsToGoogleCalendar( + { origin: ORIGIN, lessons: [baseLesson], mode: "full" }, + getAccessToken, + ); + + expect(deleteGoogleCalendarEvent).toHaveBeenCalledWith( + "test-token", + "app-calendar-id", + "gone", + expect.any(Function), + ); + expect(result.deleted).toBeGreaterThanOrEqual(1); + }); + it("does not delete events during incremental sync", async () => { const result = await syncLessonsToGoogleCalendar( { origin: ORIGIN, lessons: [baseLesson], mode: "incremental" }, diff --git a/src/seqta/utils/outlookCalendar/upsertEvent.test.ts b/src/seqta/utils/outlookCalendar/upsertEvent.test.ts index b3f284e1..a4e578a8 100644 --- a/src/seqta/utils/outlookCalendar/upsertEvent.test.ts +++ b/src/seqta/utils/outlookCalendar/upsertEvent.test.ts @@ -5,8 +5,12 @@ global.fetch = mockFetch as typeof fetch; import { deleteOutlookCalendarEvent, + listGoogleSyncedEvents, + listOutlookSyncedEvents, upsertOutlookCalendarEvent, } from "@/seqta/utils/calendarSync/remoteEvents"; +import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar"; +import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar"; describe("outlook calendar remote events", () => { beforeEach(() => { @@ -28,4 +32,101 @@ describe("outlook calendar remote events", () => { await expect(deleteOutlookCalendarEvent("token", "evt-1")).resolves.toBeUndefined(); }); + + it("lists Outlook synced events and paginates", async () => { + mockFetch + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + value: [ + { + id: "o1", + subject: "Math", + body: { content: "Synced by BetterSEQTA+\nKey: https://school.seqta.com.au:cal:1" }, + start: { dateTime: "2026-07-13T09:00:00", timeZone: "UTC" }, + end: { dateTime: "2026-07-13T10:00:00", timeZone: "UTC" }, + categories: [BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY], + }, + ], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/calendarView?$skiptoken=2", + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + value: [ + { + id: "o2", + subject: "English", + body: { content: "Synced by BetterSEQTA+\nKey: https://school.seqta.com.au:cal:2" }, + start: { dateTime: "2026-07-14T09:00:00", timeZone: "UTC" }, + end: { dateTime: "2026-07-14T10:00:00", timeZone: "UTC" }, + categories: [BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY], + }, + ], + }), + { status: 200 }, + ), + ); + + const events = await listOutlookSyncedEvents("token", { + from: "2026-07-13", + until: "2026-07-20", + }); + + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + id: "o1", + seqtaKey: "https://school.seqta.com.au:cal:1", + }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); + +describe("google calendar remote list", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it("lists Google synced events using private extended properties", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "g1", + summary: "Math", + description: "Synced by BetterSEQTA+", + start: { dateTime: "2026-07-13T09:00:00", timeZone: "UTC" }, + end: { dateTime: "2026-07-13T10:00:00", timeZone: "UTC" }, + extendedProperties: { + private: { [BSPLUS_GOOGLE_CALENDAR_EVENT_PROP]: "https://school.seqta.com.au:cal:1" }, + }, + }, + { + id: "g-skip", + summary: "Unrelated", + start: { dateTime: "2026-07-13T11:00:00", timeZone: "UTC" }, + end: { dateTime: "2026-07-13T12:00:00", timeZone: "UTC" }, + }, + ], + }), + { status: 200 }, + ), + ); + + const events = await listGoogleSyncedEvents("token", "cal-id", { + from: "2026-07-13", + until: "2026-07-20", + }); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + id: "g1", + seqtaKey: "https://school.seqta.com.au:cal:1", + date: "2026-07-13", + }); + }); });