feat: implement proper remote event checking for sync

This commit is contained in:
2026-07-13 16:03:27 +09:30
parent 34c536c2d8
commit fdf941b968
10 changed files with 772 additions and 21 deletions
@@ -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}`;
}
+13 -1
View File
@@ -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<string, string | EventMapEntry>;
@@ -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 {
@@ -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<string, { id: string; date: string; fingerprint?: string }> = {};
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 });
});
});
@@ -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<TEvent extends MappedLessonEvent> = {
export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
params: UpsertLessonEventsParams<TEvent>,
): 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<TEvent extends MappedLessonEvent>(
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<TEvent extends MappedLessonEvent>(
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<TEvent extends MappedLessonEvent>(
});
}
return { created, updated, failed, accessToken };
return { created, updated, skipped, failed, accessToken };
}
+204 -2
View File
@@ -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<string, string> };
};
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<string>,
): Promise<RemoteSyncedEvent[]> {
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<string>,
): Promise<RemoteSyncedEvent[]> {
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;
}
+55 -8
View File
@@ -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<string, unknown>,
refreshAccessToken: () => Promise<string>,
) => Promise<string>;
listSyncedEvents: (
accessToken: string,
range: SyncDateRange,
refreshAccessToken: () => Promise<string>,
) => Promise<RemoteSyncedEvent[]>;
toApiBody: (event: GoogleCalendarEventInput) => Record<string, unknown>;
};
@@ -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,
);
@@ -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+");
});
});
@@ -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<s
subject: event.summary,
body: {
contentType: "text",
content: event.description ?? "Synced by BetterSEQTA+",
content: outlookDescriptionWithKey(event.description, event.seqtaKey),
},
start: { dateTime: event.startDateTime, timeZone: event.timeZone },
end: { dateTime: event.endDateTime, timeZone: event.timeZone },
@@ -3,6 +3,8 @@ import type { SeqtaTimetableLesson } from "./types";
jest.mock("@/config/googleCalendar", () => ({
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" },
@@ -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",
});
});
});