mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
feat: implement proper remote event checking for sync
This commit is contained in:
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import browser from "webextension-polyfill";
|
|||||||
export interface EventMapEntry {
|
export interface EventMapEntry {
|
||||||
id: string;
|
id: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
/** Last-synced content fingerprint; used to skip unchanged events. */
|
||||||
|
fingerprint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventMapRecord = Record<string, string | EventMapEntry>;
|
export type EventMapRecord = Record<string, string | EventMapEntry>;
|
||||||
@@ -17,11 +19,21 @@ export function normalizeEventMapEntry(
|
|||||||
if (value == null) return undefined;
|
if (value == null) return undefined;
|
||||||
if (typeof value === "string") return { id: value, date: "" };
|
if (typeof value === "string") return { id: value, date: "" };
|
||||||
if (typeof value.id === "string" && value.id.length > 0) {
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStoredFingerprint(
|
||||||
|
value: string | EventMapEntry | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
return normalizeEventMapEntry(value)?.fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
export function getStoredEventId(
|
export function getStoredEventId(
|
||||||
value: string | EventMapEntry | undefined,
|
value: string | EventMapEntry | undefined,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
|||||||
@@ -4,8 +4,21 @@ jest.mock("@/utils/verboseLog", () => ({
|
|||||||
verboseLog: jest.fn(),
|
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 type { GoogleCalendarSyncProgress } from "@/seqta/utils/googleCalendar/types";
|
||||||
|
import { syncWindowRange } from "@/seqta/utils/googleCalendar/syncDateRange";
|
||||||
|
|
||||||
describe("reportSyncProgress", () => {
|
describe("reportSyncProgress", () => {
|
||||||
it("calls onProgress when provided", () => {
|
it("calls onProgress when provided", () => {
|
||||||
@@ -24,3 +37,189 @@ describe("reportSyncProgress", () => {
|
|||||||
expect(onProgress).toHaveBeenCalledWith(progress);
|
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 { verboseLog } from "@/utils/verboseLog";
|
||||||
import {
|
import {
|
||||||
getStoredEventId,
|
getStoredEventId,
|
||||||
|
getStoredFingerprint,
|
||||||
lessonDateFromSeqtaKey,
|
lessonDateFromSeqtaKey,
|
||||||
normalizeEventMapEntry,
|
normalizeEventMapEntry,
|
||||||
type EventMapRecord,
|
type EventMapRecord,
|
||||||
} from "@/seqta/utils/calendarSync/eventMap";
|
} from "@/seqta/utils/calendarSync/eventMap";
|
||||||
|
import { eventFingerprint } from "@/seqta/utils/calendarSync/eventFingerprint";
|
||||||
|
import type { RemoteSyncedEvent } from "@/seqta/utils/calendarSync/remoteEvents";
|
||||||
import {
|
import {
|
||||||
isDateInRange,
|
isDateInRange,
|
||||||
syncWindowRange,
|
syncWindowRange,
|
||||||
|
type SyncDateRange,
|
||||||
} from "@/seqta/utils/googleCalendar/syncDateRange";
|
} from "@/seqta/utils/googleCalendar/syncDateRange";
|
||||||
import type {
|
import type {
|
||||||
GoogleCalendarDeleteResult,
|
GoogleCalendarDeleteResult,
|
||||||
@@ -20,7 +24,12 @@ export const EVENT_MAP_PERSIST_EVERY = 10;
|
|||||||
|
|
||||||
export type MappedLessonEvent = {
|
export type MappedLessonEvent = {
|
||||||
seqtaKey: string;
|
seqtaKey: string;
|
||||||
|
summary: string;
|
||||||
|
location?: string;
|
||||||
|
description?: string;
|
||||||
startDateTime: string;
|
startDateTime: string;
|
||||||
|
endDateTime: string;
|
||||||
|
timeZone: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function reportSyncProgress(
|
export function reportSyncProgress(
|
||||||
@@ -65,15 +74,49 @@ export function entriesToPrune(
|
|||||||
if (!mapKey.startsWith(prefix)) continue;
|
if (!mapKey.startsWith(prefix)) continue;
|
||||||
const entry = normalizeEventMapEntry(raw);
|
const entry = normalizeEventMapEntry(raw);
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
const stale = entry.date
|
const missingFromTimetable = !currentMapKeys.has(mapKey);
|
||||||
? !isDateInRange(entry.date, window)
|
const outsideWindow = entry.date ? !isDateInRange(entry.date, window) : false;
|
||||||
: !currentMapKeys.has(mapKey);
|
if (missingFromTimetable || outsideWindow) {
|
||||||
if (stale) entries.push([mapKey, entry.id]);
|
entries.push([mapKey, entry.id]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
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 {
|
export function notConfiguredSyncResult(error: string): GoogleCalendarSyncResult {
|
||||||
return { success: false, configured: false, error };
|
return { success: false, configured: false, error };
|
||||||
}
|
}
|
||||||
@@ -98,10 +141,12 @@ export function formatLessonSyncResultMessage(
|
|||||||
const created = result.created ?? 0;
|
const created = result.created ?? 0;
|
||||||
const updated = result.updated ?? 0;
|
const updated = result.updated ?? 0;
|
||||||
const deleted = result.deleted ?? 0;
|
const deleted = result.deleted ?? 0;
|
||||||
|
const skipped = result.skipped ?? 0;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (created > 0) parts.push(`${created} new`);
|
if (created > 0) parts.push(`${created} new`);
|
||||||
if (updated > 0) parts.push(`${updated} updated`);
|
if (updated > 0) parts.push(`${updated} updated`);
|
||||||
if (deleted > 0) parts.push(`${deleted} removed`);
|
if (deleted > 0) parts.push(`${deleted} removed`);
|
||||||
|
if (skipped > 0) parts.push(`${skipped} unchanged`);
|
||||||
if (parts.length === 0) return `${calendarLabel} is up to date.`;
|
if (parts.length === 0) return `${calendarLabel} is up to date.`;
|
||||||
return `${calendarLabel} updated (${parts.join(", ")}).`;
|
return `${calendarLabel} updated (${parts.join(", ")}).`;
|
||||||
}
|
}
|
||||||
@@ -187,6 +232,7 @@ export function buildLessonSyncResult(
|
|||||||
created: number,
|
created: number,
|
||||||
updated: number,
|
updated: number,
|
||||||
deleted: number,
|
deleted: number,
|
||||||
|
skipped: number,
|
||||||
failed: number,
|
failed: number,
|
||||||
lastSyncAt: number,
|
lastSyncAt: number,
|
||||||
): GoogleCalendarSyncResult {
|
): GoogleCalendarSyncResult {
|
||||||
@@ -197,7 +243,7 @@ export function buildLessonSyncResult(
|
|||||||
created,
|
created,
|
||||||
updated,
|
updated,
|
||||||
deleted,
|
deleted,
|
||||||
skipped: 0,
|
skipped,
|
||||||
failed,
|
failed,
|
||||||
lastSyncAt,
|
lastSyncAt,
|
||||||
error:
|
error:
|
||||||
@@ -234,7 +280,13 @@ type UpsertLessonEventsParams<TEvent extends MappedLessonEvent> = {
|
|||||||
|
|
||||||
export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
||||||
params: UpsertLessonEventsParams<TEvent>,
|
params: UpsertLessonEventsParams<TEvent>,
|
||||||
): Promise<{ created: number; updated: number; failed: number; accessToken: string }> {
|
): Promise<{
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
accessToken: string;
|
||||||
|
}> {
|
||||||
const {
|
const {
|
||||||
events,
|
events,
|
||||||
eventMap,
|
eventMap,
|
||||||
@@ -254,16 +306,29 @@ export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
|||||||
let accessToken = await getAccessToken();
|
let accessToken = await getAccessToken();
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
let failed = initialFailed;
|
let failed = initialFailed;
|
||||||
|
|
||||||
for (let i = 0; i < events.length; i++) {
|
for (let i = 0; i < events.length; i++) {
|
||||||
const event = events[i];
|
const event = events[i];
|
||||||
const key = mapKey(origin, event.seqtaKey);
|
const key = mapKey(origin, event.seqtaKey);
|
||||||
const existingId = getStoredEventId(eventMap[key]);
|
const existingId = getStoredEventId(eventMap[key]);
|
||||||
|
const desiredFingerprint = eventFingerprint(event);
|
||||||
const progressCurrent = staleEntryCount + i + 1;
|
const progressCurrent = staleEntryCount + i + 1;
|
||||||
const progressMessage = `Syncing events (${i + 1}/${events.length})…`;
|
const progressMessage = `Syncing events (${i + 1}/${events.length})…`;
|
||||||
|
|
||||||
try {
|
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 () => {
|
const remoteId = await upsert(accessToken, existingId, event, async () => {
|
||||||
accessToken = await getAccessToken();
|
accessToken = await getAccessToken();
|
||||||
return accessToken;
|
return accessToken;
|
||||||
@@ -273,6 +338,7 @@ export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
|||||||
eventMap[key] = {
|
eventMap[key] = {
|
||||||
id: remoteId,
|
id: remoteId,
|
||||||
date: lessonDateForEvent(event.startDateTime, event.seqtaKey),
|
date: lessonDateForEvent(event.startDateTime, event.seqtaKey),
|
||||||
|
fingerprint: desiredFingerprint,
|
||||||
};
|
};
|
||||||
|
|
||||||
if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) {
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
import { GOOGLE_CALENDAR_API } from "@/config/googleCalendar";
|
import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP, GOOGLE_CALENDAR_API } from "@/config/googleCalendar";
|
||||||
import { OUTLOOK_GRAPH_API } from "@/config/outlookCalendar";
|
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(
|
async function authorizedFetch(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -138,3 +153,190 @@ export function deleteOutlookCalendarEvent(
|
|||||||
refreshAccessToken,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,13 +9,19 @@ import {
|
|||||||
deleteTrackedLessonEvents,
|
deleteTrackedLessonEvents,
|
||||||
emptyLessonsSyncResult,
|
emptyLessonsSyncResult,
|
||||||
entriesToPrune,
|
entriesToPrune,
|
||||||
|
mergeRemoteEventsIntoMap,
|
||||||
notConfiguredSyncResult,
|
notConfiguredSyncResult,
|
||||||
notConnectedSyncResult,
|
notConnectedSyncResult,
|
||||||
originEventMapEntries,
|
originEventMapEntries,
|
||||||
|
reconcileRangeForMode,
|
||||||
reportSyncProgress,
|
reportSyncProgress,
|
||||||
upsertLessonEvents,
|
upsertLessonEvents,
|
||||||
} from "@/seqta/utils/calendarSync/lessonSyncShared";
|
} from "@/seqta/utils/calendarSync/lessonSyncShared";
|
||||||
import { googleApiEventBody, mapLessonsToGoogleEvents, outlookGraphEventBody } from "@/seqta/utils/googleCalendar/eventMapper";
|
import {
|
||||||
|
googleApiEventBody,
|
||||||
|
mapLessonsToGoogleEvents,
|
||||||
|
outlookGraphEventBody,
|
||||||
|
} from "@/seqta/utils/googleCalendar/eventMapper";
|
||||||
import {
|
import {
|
||||||
readGoogleCalendarState,
|
readGoogleCalendarState,
|
||||||
writeGoogleCalendarState,
|
writeGoogleCalendarState,
|
||||||
@@ -30,14 +36,23 @@ import type {
|
|||||||
import {
|
import {
|
||||||
deleteGoogleCalendarEvent,
|
deleteGoogleCalendarEvent,
|
||||||
deleteOutlookCalendarEvent,
|
deleteOutlookCalendarEvent,
|
||||||
|
listGoogleSyncedEvents,
|
||||||
|
listOutlookSyncedEvents,
|
||||||
upsertGoogleCalendarEvent,
|
upsertGoogleCalendarEvent,
|
||||||
upsertOutlookCalendarEvent,
|
upsertOutlookCalendarEvent,
|
||||||
|
type RemoteSyncedEvent,
|
||||||
} from "@/seqta/utils/calendarSync/remoteEvents";
|
} from "@/seqta/utils/calendarSync/remoteEvents";
|
||||||
import { ensureGoogleAppCalendar } from "@/seqta/utils/googleCalendar/calendarProvisioning";
|
import { ensureGoogleAppCalendar } from "@/seqta/utils/googleCalendar/calendarProvisioning";
|
||||||
|
import {
|
||||||
|
syncWindowRange,
|
||||||
|
trailingWeekRange,
|
||||||
|
type SyncDateRange,
|
||||||
|
} from "@/seqta/utils/googleCalendar/syncDateRange";
|
||||||
import {
|
import {
|
||||||
readOutlookCalendarState,
|
readOutlookCalendarState,
|
||||||
writeOutlookCalendarState,
|
writeOutlookCalendarState,
|
||||||
} from "@/seqta/utils/outlookCalendar/storage";
|
} from "@/seqta/utils/outlookCalendar/storage";
|
||||||
|
import { verboseLog } from "@/utils/verboseLog";
|
||||||
|
|
||||||
type CalendarStoredState = {
|
type CalendarStoredState = {
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
@@ -67,6 +82,11 @@ export type CalendarLessonSyncProvider = {
|
|||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
refreshAccessToken: () => Promise<string>,
|
refreshAccessToken: () => Promise<string>,
|
||||||
) => Promise<string>;
|
) => Promise<string>;
|
||||||
|
listSyncedEvents: (
|
||||||
|
accessToken: string,
|
||||||
|
range: SyncDateRange,
|
||||||
|
refreshAccessToken: () => Promise<string>,
|
||||||
|
) => Promise<RemoteSyncedEvent[]>;
|
||||||
toApiBody: (event: GoogleCalendarEventInput) => Record<string, unknown>;
|
toApiBody: (event: GoogleCalendarEventInput) => Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,6 +121,13 @@ export const googleLessonSyncProvider: CalendarLessonSyncProvider = {
|
|||||||
body,
|
body,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
),
|
),
|
||||||
|
listSyncedEvents: async (accessToken, range, refreshAccessToken) =>
|
||||||
|
listGoogleSyncedEvents(
|
||||||
|
accessToken,
|
||||||
|
await getOrProvisionGoogleCalendarId(accessToken),
|
||||||
|
range,
|
||||||
|
refreshAccessToken,
|
||||||
|
),
|
||||||
toApiBody: googleApiEventBody,
|
toApiBody: googleApiEventBody,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,6 +142,8 @@ export const outlookLessonSyncProvider: CalendarLessonSyncProvider = {
|
|||||||
deleteOutlookCalendarEvent(accessToken, eventId, refreshAccessToken),
|
deleteOutlookCalendarEvent(accessToken, eventId, refreshAccessToken),
|
||||||
upsertEvent: (accessToken, existingId, body, refreshAccessToken) =>
|
upsertEvent: (accessToken, existingId, body, refreshAccessToken) =>
|
||||||
upsertOutlookCalendarEvent(accessToken, existingId, body, refreshAccessToken),
|
upsertOutlookCalendarEvent(accessToken, existingId, body, refreshAccessToken),
|
||||||
|
listSyncedEvents: (accessToken, range, refreshAccessToken) =>
|
||||||
|
listOutlookSyncedEvents(accessToken, range, refreshAccessToken),
|
||||||
toApiBody: outlookGraphEventBody,
|
toApiBody: outlookGraphEventBody,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,6 +180,28 @@ export async function syncLessonsToCalendar(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const eventMap = { ...(state.eventMap ?? {}) };
|
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 currentMapKeys = new Set(events.map((event) => eventMapKey(request.origin, event.seqtaKey)));
|
||||||
const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys);
|
const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys);
|
||||||
const totalSteps = staleEntries.length + events.length;
|
const totalSteps = staleEntries.length + events.length;
|
||||||
@@ -179,13 +230,8 @@ export async function syncLessonsToCalendar(
|
|||||||
initialFailed: staleResult.failed,
|
initialFailed: staleResult.failed,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
mapKey: eventMapKey,
|
mapKey: eventMapKey,
|
||||||
upsert: (accessToken, existingId, event, refreshAccessToken) =>
|
upsert: (token, existingId, event, refresh) =>
|
||||||
provider.upsertEvent(
|
provider.upsertEvent(token, existingId, provider.toApiBody(event), refresh),
|
||||||
accessToken,
|
|
||||||
existingId,
|
|
||||||
provider.toApiBody(event),
|
|
||||||
refreshAccessToken,
|
|
||||||
),
|
|
||||||
writeState: provider.writeState,
|
writeState: provider.writeState,
|
||||||
onProgress: options.onProgress,
|
onProgress: options.onProgress,
|
||||||
logLabel: provider.label,
|
logLabel: provider.label,
|
||||||
@@ -210,6 +256,7 @@ export async function syncLessonsToCalendar(
|
|||||||
upsertResult.created,
|
upsertResult.created,
|
||||||
upsertResult.updated,
|
upsertResult.updated,
|
||||||
staleResult.deleted,
|
staleResult.deleted,
|
||||||
|
upsertResult.skipped,
|
||||||
upsertResult.failed,
|
upsertResult.failed,
|
||||||
lastSyncAt,
|
lastSyncAt,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "@jest/globals";
|
|||||||
import {
|
import {
|
||||||
lessonToGoogleEvent,
|
lessonToGoogleEvent,
|
||||||
mapLessonsToGoogleEvents,
|
mapLessonsToGoogleEvents,
|
||||||
|
outlookGraphEventBody,
|
||||||
seqtaLessonKey,
|
seqtaLessonKey,
|
||||||
shouldSyncLesson,
|
shouldSyncLesson,
|
||||||
} from "./eventMapper";
|
} from "./eventMapper";
|
||||||
@@ -62,3 +63,14 @@ describe("mapLessonsToGoogleEvents", () => {
|
|||||||
expect(events).toHaveLength(1);
|
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_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar";
|
||||||
import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar";
|
import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar";
|
||||||
|
import { outlookDescriptionWithKey } from "@/seqta/utils/calendarSync/eventFingerprint";
|
||||||
import type { GoogleCalendarEventInput, SeqtaTimetableLesson } from "./types";
|
import type { GoogleCalendarEventInput, SeqtaTimetableLesson } from "./types";
|
||||||
|
|
||||||
const SKIP_TYPES = new Set(["note", "holiday", "assembly-note"]);
|
const SKIP_TYPES = new Set(["note", "holiday", "assembly-note"]);
|
||||||
@@ -100,7 +101,7 @@ export function outlookGraphEventBody(event: GoogleCalendarEventInput): Record<s
|
|||||||
subject: event.summary,
|
subject: event.summary,
|
||||||
body: {
|
body: {
|
||||||
contentType: "text",
|
contentType: "text",
|
||||||
content: event.description ?? "Synced by BetterSEQTA+",
|
content: outlookDescriptionWithKey(event.description, event.seqtaKey),
|
||||||
},
|
},
|
||||||
start: { dateTime: event.startDateTime, timeZone: event.timeZone },
|
start: { dateTime: event.startDateTime, timeZone: event.timeZone },
|
||||||
end: { dateTime: event.endDateTime, timeZone: event.timeZone },
|
end: { dateTime: event.endDateTime, timeZone: event.timeZone },
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { SeqtaTimetableLesson } from "./types";
|
|||||||
|
|
||||||
jest.mock("@/config/googleCalendar", () => ({
|
jest.mock("@/config/googleCalendar", () => ({
|
||||||
isGoogleCalendarConfigured: jest.fn(() => true),
|
isGoogleCalendarConfigured: jest.fn(() => true),
|
||||||
|
BSPLUS_GOOGLE_CALENDAR_EVENT_PROP: "bsplusSeqtaKey",
|
||||||
|
GOOGLE_CALENDAR_API: "https://www.googleapis.com/calendar/v3",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@/utils/verboseLog", () => ({
|
jest.mock("@/utils/verboseLog", () => ({
|
||||||
@@ -27,14 +29,21 @@ jest.mock("@/seqta/utils/calendarSync/settings", () => ({
|
|||||||
jest.mock("@/seqta/utils/calendarSync/remoteEvents", () => ({
|
jest.mock("@/seqta/utils/calendarSync/remoteEvents", () => ({
|
||||||
upsertGoogleCalendarEvent: jest.fn(),
|
upsertGoogleCalendarEvent: jest.fn(),
|
||||||
deleteGoogleCalendarEvent: jest.fn(),
|
deleteGoogleCalendarEvent: jest.fn(),
|
||||||
|
listGoogleSyncedEvents: jest.fn(async () => []),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage";
|
import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage";
|
||||||
import {
|
import {
|
||||||
deleteGoogleCalendarEvent,
|
deleteGoogleCalendarEvent,
|
||||||
|
listGoogleSyncedEvents,
|
||||||
upsertGoogleCalendarEvent,
|
upsertGoogleCalendarEvent,
|
||||||
} from "@/seqta/utils/calendarSync/remoteEvents";
|
} 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";
|
import { syncWindowRange } from "@/seqta/utils/googleCalendar/syncDateRange";
|
||||||
|
|
||||||
@@ -67,6 +76,7 @@ describe("syncLessonsToGoogleCalendar", () => {
|
|||||||
});
|
});
|
||||||
jest.mocked(upsertGoogleCalendarEvent).mockResolvedValue("google-existing");
|
jest.mocked(upsertGoogleCalendarEvent).mockResolvedValue("google-existing");
|
||||||
jest.mocked(deleteGoogleCalendarEvent).mockResolvedValue(undefined);
|
jest.mocked(deleteGoogleCalendarEvent).mockResolvedValue(undefined);
|
||||||
|
jest.mocked(listGoogleSyncedEvents).mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates existing events and removes stale tracked events on full sync", async () => {
|
it("updates existing events and removes stale tracked events on full sync", async () => {
|
||||||
@@ -75,6 +85,7 @@ describe("syncLessonsToGoogleCalendar", () => {
|
|||||||
getAccessToken,
|
getAccessToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(listGoogleSyncedEvents).toHaveBeenCalled();
|
||||||
expect(deleteGoogleCalendarEvent).toHaveBeenCalled();
|
expect(deleteGoogleCalendarEvent).toHaveBeenCalled();
|
||||||
expect(upsertGoogleCalendarEvent).toHaveBeenCalledTimes(1);
|
expect(upsertGoogleCalendarEvent).toHaveBeenCalledTimes(1);
|
||||||
expect(result).toMatchObject({
|
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 () => {
|
it("does not delete events during incremental sync", async () => {
|
||||||
const result = await syncLessonsToGoogleCalendar(
|
const result = await syncLessonsToGoogleCalendar(
|
||||||
{ origin: ORIGIN, lessons: [baseLesson], mode: "incremental" },
|
{ origin: ORIGIN, lessons: [baseLesson], mode: "incremental" },
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ global.fetch = mockFetch as typeof fetch;
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
deleteOutlookCalendarEvent,
|
deleteOutlookCalendarEvent,
|
||||||
|
listGoogleSyncedEvents,
|
||||||
|
listOutlookSyncedEvents,
|
||||||
upsertOutlookCalendarEvent,
|
upsertOutlookCalendarEvent,
|
||||||
} from "@/seqta/utils/calendarSync/remoteEvents";
|
} 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", () => {
|
describe("outlook calendar remote events", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -28,4 +32,101 @@ describe("outlook calendar remote events", () => {
|
|||||||
|
|
||||||
await expect(deleteOutlookCalendarEvent("token", "evt-1")).resolves.toBeUndefined();
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user