mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
fix: fix minor bugs
This commit is contained in:
@@ -669,22 +669,27 @@
|
||||
</div>
|
||||
|
||||
{#snippet googleIcon()}
|
||||
<svg viewBox="0 0 24 24">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="-3 0 262 262"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
|
||||
fill="#4285F4"
|
||||
d="M22 12c0-.96-.08-1.88-.24-2.76H12v5.22h5.68c-.24 1.28-.96 2.44-2.04 3.18v2.64h3.3c1.92-1.76 3.06-4.36 3.06-7.28z"
|
||||
/>
|
||||
<path
|
||||
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
|
||||
fill="#34A853"
|
||||
d="M12 22c2.76 0 5.08-.92 6.78-2.5l-3.3-2.64c-.92.62-2.1.98-3.48.98-2.68 0-4.96-1.8-5.78-4.22H2.18v2.72A10 10 0 0 0 12 22z"
|
||||
/>
|
||||
<path
|
||||
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
|
||||
fill="#FBBC05"
|
||||
d="M6.22 13.62A5.98 5.98 0 0 1 5.82 12c0-.56.1-1.1.28-1.62V7.66H2.18A10 10 0 0 0 2 12c0 1.62.38 3.16 1.06 4.52l3.16-2.9z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.5 0 2.84.52 3.9 1.54l2.92-2.92C17.08 2.34 14.76 1.2 12 1.2 7.54 1.2 3.72 3.94 2.18 7.66l4.04 3.14c.82-2.42 3.1-4.22 5.78-4.22z"
|
||||
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
|
||||
fill="#EB4335"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
} from "./eventFingerprint";
|
||||
import {
|
||||
buildLessonSyncResult,
|
||||
collectOriginDeleteEntries,
|
||||
entriesToPrune,
|
||||
formatLessonSyncResultMessage,
|
||||
mapPool,
|
||||
mergeRemoteEventsIntoMap,
|
||||
reportSyncProgress,
|
||||
upsertLessonEvents,
|
||||
@@ -166,6 +168,45 @@ describe("mergeRemoteEventsIntoMap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectOriginDeleteEntries", () => {
|
||||
const origin = "https://school.seqta.com.au";
|
||||
const mapKey = (o: string, seqtaKey: string) => `${o}::${seqtaKey}`;
|
||||
|
||||
it("includes local map entries and matching remote events", () => {
|
||||
const eventMap = {
|
||||
[`${origin}::${origin}:cal:1`]: { id: "local-1", date: "2026-07-13" },
|
||||
};
|
||||
const entries = collectOriginDeleteEntries(
|
||||
eventMap,
|
||||
origin,
|
||||
[
|
||||
{
|
||||
seqtaKey: `${origin}:cal:2`,
|
||||
id: "remote-2",
|
||||
date: "2026-07-14",
|
||||
fingerprint: "fp",
|
||||
},
|
||||
{
|
||||
seqtaKey: "",
|
||||
id: "orphan-3",
|
||||
date: "2026-07-15",
|
||||
fingerprint: "fp",
|
||||
},
|
||||
{
|
||||
seqtaKey: "https://other.seqta.com.au:cal:9",
|
||||
id: "other",
|
||||
date: "2026-07-15",
|
||||
fingerprint: "fp",
|
||||
},
|
||||
],
|
||||
mapKey,
|
||||
);
|
||||
|
||||
const ids = entries.map(([, id]) => id).sort();
|
||||
expect(ids).toEqual(["local-1", "orphan-3", "remote-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLessonSyncResultMessage", () => {
|
||||
it("includes unchanged counts", () => {
|
||||
expect(
|
||||
@@ -222,4 +263,61 @@ describe("upsertLessonEvents skip unchanged", () => {
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ created: 0, updated: 0, skipped: 1, failed: 0 });
|
||||
});
|
||||
|
||||
it("runs upserts concurrently", async () => {
|
||||
const origin = "https://school.seqta.com.au";
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
const events = Array.from({ length: 6 }, (_, i) => ({
|
||||
seqtaKey: `${origin}:cal:${i}`,
|
||||
summary: `Class ${i}`,
|
||||
description: "Synced by BetterSEQTA+",
|
||||
startDateTime: "2026-07-13T09:00:00",
|
||||
endDateTime: "2026-07-13T10:00:00",
|
||||
timeZone: "UTC",
|
||||
}));
|
||||
|
||||
const upsert = jest.fn(async () => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
inFlight -= 1;
|
||||
return `id-${Math.random()}`;
|
||||
});
|
||||
|
||||
const result = await upsertLessonEvents({
|
||||
events,
|
||||
eventMap: {},
|
||||
origin,
|
||||
staleEntryCount: 0,
|
||||
totalSteps: events.length,
|
||||
lastSyncAt: Date.now(),
|
||||
initialFailed: 0,
|
||||
getAccessToken: async () => "token",
|
||||
mapKey: (o, k) => `${o}::${k}`,
|
||||
upsert,
|
||||
writeState: async () => undefined,
|
||||
logLabel: "Test",
|
||||
concurrency: 2,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(6);
|
||||
expect(upsert).toHaveBeenCalledTimes(6);
|
||||
expect(maxInFlight).toBeGreaterThan(1);
|
||||
expect(maxInFlight).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapPool", () => {
|
||||
it("limits concurrency", async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
await mapPool([1, 2, 3, 4, 5], 2, async () => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
inFlight -= 1;
|
||||
});
|
||||
expect(maxInFlight).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,34 @@ import type {
|
||||
|
||||
export const EVENT_MAP_PERSIST_EVERY = 10;
|
||||
|
||||
/** Max concurrent Google/Outlook create/update/delete requests during sync. */
|
||||
export const SYNC_CONCURRENCY = 2;
|
||||
|
||||
/** Same cap for deletes to avoid provider rate limits. */
|
||||
export const SYNC_DELETE_CONCURRENCY = 2;
|
||||
|
||||
/** Run async work over items with a fixed concurrency pool. */
|
||||
export async function mapPool<T>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
const limit = Math.max(1, Math.min(concurrency, items.length));
|
||||
let nextIndex = 0;
|
||||
|
||||
async function runWorker(): Promise<void> {
|
||||
while (true) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (index >= items.length) return;
|
||||
await worker(items[index], index);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: limit }, () => runWorker()));
|
||||
}
|
||||
|
||||
export type MappedLessonEvent = {
|
||||
seqtaKey: string;
|
||||
summary: string;
|
||||
@@ -104,6 +132,41 @@ export function mergeRemoteEventsIntoMap(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deduped delete list from local map + remote events for an origin.
|
||||
* Unkeyed remote events (empty seqtaKey) are included as orphan removals.
|
||||
*/
|
||||
export function collectOriginDeleteEntries(
|
||||
eventMap: EventMapRecord,
|
||||
origin: string,
|
||||
remoteEvents: RemoteSyncedEvent[],
|
||||
mapKey: (origin: string, seqtaKey: string) => string,
|
||||
): Array<[string, string]> {
|
||||
const byId = new Map<string, string>();
|
||||
|
||||
for (const [key, id] of originEventMapEntries(eventMap, origin)) {
|
||||
byId.set(id, key);
|
||||
}
|
||||
|
||||
for (const remote of remoteEvents) {
|
||||
if (remote.seqtaKey && !remote.seqtaKey.startsWith(origin)) continue;
|
||||
if (byId.has(remote.id)) continue;
|
||||
const key = remote.seqtaKey
|
||||
? mapKey(origin, remote.seqtaKey)
|
||||
: `${origin}::__orphan__:${remote.id}`;
|
||||
byId.set(remote.id, key);
|
||||
}
|
||||
|
||||
return Array.from(byId.entries()).map(([id, key]) => [key, id]);
|
||||
}
|
||||
|
||||
export function clearOriginEventMapEntries(eventMap: EventMapRecord, origin: string): void {
|
||||
const prefix = `${origin}::`;
|
||||
for (const key of Object.keys(eventMap)) {
|
||||
if (key.startsWith(prefix)) delete eventMap[key];
|
||||
}
|
||||
}
|
||||
|
||||
export function reconcileRangeForMode(
|
||||
mode: "full" | "incremental",
|
||||
weeksAhead: number,
|
||||
@@ -184,6 +247,7 @@ export async function deleteTrackedLessonEvents(
|
||||
progressOffset?: number;
|
||||
progressTotal?: number;
|
||||
logLabel: string;
|
||||
concurrency?: number;
|
||||
},
|
||||
): Promise<{ deleted: number; failed: number }> {
|
||||
if (entries.length === 0) return { deleted: 0, failed: 0 };
|
||||
@@ -194,18 +258,22 @@ export async function deleteTrackedLessonEvents(
|
||||
progressOffset = 0,
|
||||
progressTotal = 0,
|
||||
logLabel,
|
||||
concurrency = SYNC_DELETE_CONCURRENCY,
|
||||
} = options;
|
||||
|
||||
let accessToken = await getAccessToken();
|
||||
let deleted = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const [mapKey, eventId] of entries) {
|
||||
try {
|
||||
await deleteEvent(accessToken, eventId, async () => {
|
||||
const refreshAccessToken = async () => {
|
||||
accessToken = await getAccessToken();
|
||||
return accessToken;
|
||||
});
|
||||
};
|
||||
let deleted = 0;
|
||||
let failed = 0;
|
||||
let completed = 0;
|
||||
let persistChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
await mapPool(entries, concurrency, async ([mapKey, eventId]) => {
|
||||
try {
|
||||
await deleteEvent(accessToken, eventId, refreshAccessToken);
|
||||
delete eventMap[mapKey];
|
||||
deleted += 1;
|
||||
} catch (err) {
|
||||
@@ -213,16 +281,22 @@ export async function deleteTrackedLessonEvents(
|
||||
failed += 1;
|
||||
}
|
||||
|
||||
completed += 1;
|
||||
reportSyncProgress(onProgress, {
|
||||
phase: "deleting",
|
||||
current: progressOffset + deleted + failed,
|
||||
current: progressOffset + completed,
|
||||
total: progressTotal,
|
||||
message: `Removing old events (${deleted + failed}/${entries.length})…`,
|
||||
message: `Removing old events (${completed}/${entries.length})…`,
|
||||
});
|
||||
|
||||
if (persistProgress && (deleted + failed) % EVENT_MAP_PERSIST_EVERY === 0) {
|
||||
await writeState({ eventMap });
|
||||
if (persistProgress && completed % EVENT_MAP_PERSIST_EVERY === 0) {
|
||||
persistChain = persistChain.then(() => writeState({ eventMap }));
|
||||
await persistChain;
|
||||
}
|
||||
});
|
||||
|
||||
if (persistProgress) {
|
||||
await persistChain;
|
||||
}
|
||||
|
||||
return { deleted, failed };
|
||||
@@ -276,6 +350,7 @@ type UpsertLessonEventsParams<TEvent extends MappedLessonEvent> = {
|
||||
}) => Promise<unknown>;
|
||||
onProgress?: GoogleCalendarSyncOptions["onProgress"];
|
||||
logLabel: string;
|
||||
concurrency?: number;
|
||||
};
|
||||
|
||||
export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
||||
@@ -301,38 +376,38 @@ export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
||||
writeState,
|
||||
onProgress,
|
||||
logLabel,
|
||||
concurrency = SYNC_CONCURRENCY,
|
||||
} = params;
|
||||
|
||||
let accessToken = await getAccessToken();
|
||||
const refreshAccessToken = async () => {
|
||||
accessToken = await getAccessToken();
|
||||
return accessToken;
|
||||
};
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let failed = initialFailed;
|
||||
let completed = 0;
|
||||
let persistChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
const persistState = () => {
|
||||
persistChain = persistChain.then(() =>
|
||||
writeState({ eventMap, lastSyncAt, lastSyncOrigin: origin }),
|
||||
);
|
||||
return persistChain;
|
||||
};
|
||||
|
||||
await mapPool(events, concurrency, async (event) => {
|
||||
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;
|
||||
});
|
||||
} else {
|
||||
const remoteId = await upsert(accessToken, existingId, event, refreshAccessToken);
|
||||
if (existingId) updated += 1;
|
||||
else created += 1;
|
||||
eventMap[key] = {
|
||||
@@ -340,21 +415,27 @@ export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
||||
date: lessonDateForEvent(event.startDateTime, event.seqtaKey),
|
||||
fingerprint: desiredFingerprint,
|
||||
};
|
||||
|
||||
if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) {
|
||||
await writeState({ eventMap, lastSyncAt, lastSyncOrigin: origin });
|
||||
}
|
||||
} catch (err) {
|
||||
verboseLog(`[BetterSEQTA+] ${logLabel} event sync failed:`, err);
|
||||
failed += 1;
|
||||
}
|
||||
|
||||
completed += 1;
|
||||
reportSyncProgress(onProgress, {
|
||||
phase: "upserting",
|
||||
current: progressCurrent,
|
||||
current: staleEntryCount + completed,
|
||||
total: totalSteps,
|
||||
message: progressMessage,
|
||||
message: `Syncing events (${completed}/${events.length})…`,
|
||||
});
|
||||
|
||||
if (completed % EVENT_MAP_PERSIST_EVERY === 0) {
|
||||
await persistState();
|
||||
}
|
||||
});
|
||||
|
||||
if (events.length > 0) {
|
||||
await persistState();
|
||||
}
|
||||
|
||||
return { created, updated, skipped, failed, accessToken };
|
||||
|
||||
@@ -16,20 +16,47 @@ export type RemoteSyncedEvent = {
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type ListSyncedEventsOptions = {
|
||||
/** Include category/calendar events that are missing a seqta key (legacy orphans). */
|
||||
includeUnkeyed?: boolean;
|
||||
};
|
||||
|
||||
const MAX_RETRIES = 4;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isRetryableStatus(status: number): boolean {
|
||||
return status === 429 || status === 500 || status === 502 || status === 503 || status === 403;
|
||||
}
|
||||
|
||||
async function authorizedFetch(
|
||||
accessToken: string,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
refreshAccessToken?: () => Promise<string>,
|
||||
): Promise<Response> {
|
||||
let token = accessToken;
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: { Authorization: `Bearer ${accessToken}`, ...init.headers },
|
||||
headers: { Authorization: `Bearer ${token}`, ...init.headers },
|
||||
});
|
||||
if (res.status === 401 && refreshAccessToken) {
|
||||
return authorizedFetch(await refreshAccessToken(), url, init);
|
||||
token = await refreshAccessToken();
|
||||
continue;
|
||||
}
|
||||
if (isRetryableStatus(res.status) && attempt < MAX_RETRIES - 1) {
|
||||
await sleep(250 * 2 ** attempt + Math.floor(Math.random() * 100));
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: { Authorization: `Bearer ${token}`, ...init.headers },
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertRemoteEvent(
|
||||
@@ -182,9 +209,13 @@ type GoogleListItem = {
|
||||
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;
|
||||
function googleItemToRemote(
|
||||
item: GoogleListItem,
|
||||
includeUnkeyed: boolean,
|
||||
): RemoteSyncedEvent | null {
|
||||
if (!item.id) return null;
|
||||
const seqtaKey = item.extendedProperties?.private?.[BSPLUS_GOOGLE_CALENDAR_EVENT_PROP] ?? "";
|
||||
if (!seqtaKey && !includeUnkeyed) 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";
|
||||
@@ -208,7 +239,9 @@ export async function listGoogleSyncedEvents(
|
||||
calendarId: string,
|
||||
range: SyncDateRange,
|
||||
refreshAccessToken?: () => Promise<string>,
|
||||
options: ListSyncedEventsOptions = {},
|
||||
): Promise<RemoteSyncedEvent[]> {
|
||||
const includeUnkeyed = options.includeUnkeyed === true;
|
||||
const encodedCalendar = encodeURIComponent(calendarId);
|
||||
const out: RemoteSyncedEvent[] = [];
|
||||
let pageToken: string | undefined;
|
||||
@@ -241,7 +274,7 @@ export async function listGoogleSyncedEvents(
|
||||
}
|
||||
|
||||
for (const item of json.items ?? []) {
|
||||
const mapped = googleItemToRemote(item);
|
||||
const mapped = googleItemToRemote(item, includeUnkeyed);
|
||||
if (mapped) out.push(mapped);
|
||||
}
|
||||
pageToken = json.nextPageToken;
|
||||
@@ -260,13 +293,16 @@ type OutlookListItem = {
|
||||
categories?: string[];
|
||||
};
|
||||
|
||||
function outlookItemToRemote(item: OutlookListItem): RemoteSyncedEvent | null {
|
||||
function outlookItemToRemote(
|
||||
item: OutlookListItem,
|
||||
includeUnkeyed: boolean,
|
||||
): 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 seqtaKey = parseOutlookSeqtaKey(bodyContent) ?? "";
|
||||
if (!seqtaKey && !includeUnkeyed) return null;
|
||||
|
||||
const startDateTime = (item.start?.dateTime ?? "").replace(/\.\d+$/, "");
|
||||
const endDateTime = (item.end?.dateTime ?? "").replace(/\.\d+$/, "");
|
||||
@@ -300,7 +336,9 @@ export async function listOutlookSyncedEvents(
|
||||
accessToken: string,
|
||||
range: SyncDateRange,
|
||||
refreshAccessToken?: () => Promise<string>,
|
||||
options: ListSyncedEventsOptions = {},
|
||||
): Promise<RemoteSyncedEvent[]> {
|
||||
const includeUnkeyed = options.includeUnkeyed === true;
|
||||
const out: RemoteSyncedEvent[] = [];
|
||||
const params = new URLSearchParams({
|
||||
startDateTime: toRfc3339Start(range.from),
|
||||
@@ -332,7 +370,7 @@ export async function listOutlookSyncedEvents(
|
||||
}
|
||||
|
||||
for (const item of json.value ?? []) {
|
||||
const mapped = outlookItemToRemote(item);
|
||||
const mapped = outlookItemToRemote(item, includeUnkeyed);
|
||||
if (mapped) out.push(mapped);
|
||||
}
|
||||
nextUrl = json["@odata.nextLink"];
|
||||
|
||||
@@ -6,33 +6,18 @@ import type { EventMapRecord } from "@/seqta/utils/calendarSync/eventMap";
|
||||
import {
|
||||
buildDeleteSyncResult,
|
||||
buildLessonSyncResult,
|
||||
clearOriginEventMapEntries,
|
||||
collectOriginDeleteEntries,
|
||||
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 {
|
||||
readGoogleCalendarState,
|
||||
writeGoogleCalendarState,
|
||||
} from "@/seqta/utils/googleCalendar/storage";
|
||||
import type {
|
||||
GoogleCalendarDeleteResult,
|
||||
GoogleCalendarEventInput,
|
||||
GoogleCalendarSyncOptions,
|
||||
GoogleCalendarSyncRequest,
|
||||
GoogleCalendarSyncResult,
|
||||
} from "@/seqta/utils/googleCalendar/types";
|
||||
import {
|
||||
deleteGoogleCalendarEvent,
|
||||
deleteOutlookCalendarEvent,
|
||||
@@ -40,14 +25,32 @@ import {
|
||||
listOutlookSyncedEvents,
|
||||
upsertGoogleCalendarEvent,
|
||||
upsertOutlookCalendarEvent,
|
||||
type ListSyncedEventsOptions,
|
||||
type RemoteSyncedEvent,
|
||||
} from "@/seqta/utils/calendarSync/remoteEvents";
|
||||
import {
|
||||
googleApiEventBody,
|
||||
mapLessonsToGoogleEvents,
|
||||
outlookGraphEventBody,
|
||||
} from "@/seqta/utils/googleCalendar/eventMapper";
|
||||
import { ensureGoogleAppCalendar } from "@/seqta/utils/googleCalendar/calendarProvisioning";
|
||||
import {
|
||||
readGoogleCalendarState,
|
||||
writeGoogleCalendarState,
|
||||
} from "@/seqta/utils/googleCalendar/storage";
|
||||
import {
|
||||
syncWindowRange,
|
||||
trailingWeekRange,
|
||||
wideCleanupRange,
|
||||
type SyncDateRange,
|
||||
} from "@/seqta/utils/googleCalendar/syncDateRange";
|
||||
import type {
|
||||
GoogleCalendarDeleteResult,
|
||||
GoogleCalendarEventInput,
|
||||
GoogleCalendarSyncOptions,
|
||||
GoogleCalendarSyncRequest,
|
||||
GoogleCalendarSyncResult,
|
||||
} from "@/seqta/utils/googleCalendar/types";
|
||||
import {
|
||||
readOutlookCalendarState,
|
||||
writeOutlookCalendarState,
|
||||
@@ -86,6 +89,7 @@ export type CalendarLessonSyncProvider = {
|
||||
accessToken: string,
|
||||
range: SyncDateRange,
|
||||
refreshAccessToken: () => Promise<string>,
|
||||
options?: ListSyncedEventsOptions,
|
||||
) => Promise<RemoteSyncedEvent[]>;
|
||||
toApiBody: (event: GoogleCalendarEventInput) => Record<string, unknown>;
|
||||
};
|
||||
@@ -121,12 +125,13 @@ export const googleLessonSyncProvider: CalendarLessonSyncProvider = {
|
||||
body,
|
||||
refreshAccessToken,
|
||||
),
|
||||
listSyncedEvents: async (accessToken, range, refreshAccessToken) =>
|
||||
listSyncedEvents: async (accessToken, range, refreshAccessToken, options) =>
|
||||
listGoogleSyncedEvents(
|
||||
accessToken,
|
||||
await getOrProvisionGoogleCalendarId(accessToken),
|
||||
range,
|
||||
refreshAccessToken,
|
||||
options,
|
||||
),
|
||||
toApiBody: googleApiEventBody,
|
||||
};
|
||||
@@ -142,8 +147,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),
|
||||
listSyncedEvents: (accessToken, range, refreshAccessToken, options) =>
|
||||
listOutlookSyncedEvents(accessToken, range, refreshAccessToken, options),
|
||||
toApiBody: outlookGraphEventBody,
|
||||
};
|
||||
|
||||
@@ -168,10 +173,6 @@ export async function syncLessonsToCalendar(
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
const events = mapLessonsToGoogleEvents(request.origin, request.lessons, timeZone);
|
||||
|
||||
if (events.length === 0 && mode === "full") {
|
||||
return emptyLessonsSyncResult();
|
||||
}
|
||||
|
||||
reportSyncProgress(options.onProgress, {
|
||||
phase: "preparing",
|
||||
current: 0,
|
||||
@@ -204,7 +205,7 @@ export async function syncLessonsToCalendar(
|
||||
|
||||
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;
|
||||
const totalSteps = Math.max(staleEntries.length + events.length, 1);
|
||||
const lastSyncAt = Date.now();
|
||||
|
||||
const staleResult = await deleteTrackedLessonEvents(
|
||||
@@ -220,6 +221,24 @@ export async function syncLessonsToCalendar(
|
||||
},
|
||||
);
|
||||
|
||||
if (events.length === 0 && mode === "full") {
|
||||
await provider.writeState({
|
||||
eventMap,
|
||||
lastSyncAt,
|
||||
lastSyncOrigin: request.origin,
|
||||
});
|
||||
reportSyncProgress(options.onProgress, {
|
||||
phase: "done",
|
||||
current: totalSteps,
|
||||
total: totalSteps,
|
||||
message: "Sync complete",
|
||||
});
|
||||
if (staleResult.deleted > 0) {
|
||||
return buildLessonSyncResult(0, 0, staleResult.deleted, 0, staleResult.failed, lastSyncAt);
|
||||
}
|
||||
return emptyLessonsSyncResult();
|
||||
}
|
||||
|
||||
const upsertResult = await upsertLessonEvents({
|
||||
events,
|
||||
eventMap,
|
||||
@@ -282,19 +301,40 @@ export async function deleteSyncedEventsFromCalendar(
|
||||
return { success: false, configured: true, connected: false, error: provider.notConnectedError };
|
||||
}
|
||||
|
||||
const entries = originEventMapEntries(state.eventMap ?? {}, origin);
|
||||
if (entries.length === 0) {
|
||||
return { success: true, configured: true, connected: true, deleted: 0, failed: 0 };
|
||||
}
|
||||
|
||||
reportSyncProgress(options.onProgress, {
|
||||
phase: "preparing",
|
||||
current: 0,
|
||||
total: entries.length,
|
||||
total: 1,
|
||||
message: "Preparing removal…",
|
||||
});
|
||||
|
||||
const eventMap = { ...(state.eventMap ?? {}) };
|
||||
let accessToken = await getAccessToken();
|
||||
const refreshAccessToken = async () => {
|
||||
accessToken = await getAccessToken();
|
||||
return accessToken;
|
||||
};
|
||||
|
||||
let remoteEvents: RemoteSyncedEvent[] = [];
|
||||
try {
|
||||
remoteEvents = await provider.listSyncedEvents(
|
||||
accessToken,
|
||||
wideCleanupRange(),
|
||||
refreshAccessToken,
|
||||
{ includeUnkeyed: true },
|
||||
);
|
||||
mergeRemoteEventsIntoMap(eventMap, origin, remoteEvents, eventMapKey);
|
||||
} catch (err) {
|
||||
verboseLog(`[BetterSEQTA+] ${provider.label} cleanup list failed:`, err);
|
||||
}
|
||||
|
||||
const entries = collectOriginDeleteEntries(eventMap, origin, remoteEvents, eventMapKey);
|
||||
if (entries.length === 0) {
|
||||
clearOriginEventMapEntries(eventMap, origin);
|
||||
await provider.writeState({ eventMap });
|
||||
return { success: true, configured: true, connected: true, deleted: 0, failed: 0 };
|
||||
}
|
||||
|
||||
const { deleted, failed } = await deleteTrackedLessonEvents(
|
||||
entries,
|
||||
eventMap,
|
||||
@@ -309,6 +349,10 @@ export async function deleteSyncedEventsFromCalendar(
|
||||
},
|
||||
);
|
||||
|
||||
// Only wipe remaining origin keys when every delete succeeded — keep failed IDs for retry.
|
||||
if (failed === 0) {
|
||||
clearOriginEventMapEntries(eventMap, origin);
|
||||
}
|
||||
await provider.writeState({ eventMap });
|
||||
|
||||
reportSyncProgress(options.onProgress, {
|
||||
|
||||
@@ -40,3 +40,13 @@ export function droppedWeekRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS): SyncD
|
||||
export function isDateInRange(date: string, range: SyncDateRange): boolean {
|
||||
return date >= range.from && date <= range.until;
|
||||
}
|
||||
|
||||
/** Wide range used when removing all synced events (covers past + future terms). */
|
||||
export function wideCleanupRange(years = 3): SyncDateRange {
|
||||
const now = new Date();
|
||||
const from = new Date(now);
|
||||
from.setFullYear(from.getFullYear() - years);
|
||||
const until = new Date(now);
|
||||
until.setFullYear(until.getFullYear() + years);
|
||||
return { from: toISODate(from), until: toISODate(until) };
|
||||
}
|
||||
|
||||
@@ -222,6 +222,7 @@ describe("deleteSyncedEventsFromGoogleCalendar", () => {
|
||||
it("deletes only events for the requested origin", async () => {
|
||||
const result = await deleteSyncedEventsFromGoogleCalendar(ORIGIN, getAccessToken);
|
||||
|
||||
expect(listGoogleSyncedEvents).toHaveBeenCalled();
|
||||
expect(deleteGoogleCalendarEvent).toHaveBeenCalledTimes(2);
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
@@ -229,4 +230,31 @@ describe("deleteSyncedEventsFromGoogleCalendar", () => {
|
||||
failed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("also deletes remote orphans found by list", async () => {
|
||||
jest.mocked(readGoogleCalendarState).mockResolvedValue({
|
||||
refreshToken: "refresh",
|
||||
calendarId: "app-calendar-id",
|
||||
eventMap: {},
|
||||
});
|
||||
jest.mocked(listGoogleSyncedEvents).mockResolvedValue([
|
||||
{
|
||||
seqtaKey: `${ORIGIN}:cal:1`,
|
||||
id: "remote-a",
|
||||
date: "2026-06-27",
|
||||
fingerprint: "fp",
|
||||
},
|
||||
{
|
||||
seqtaKey: "",
|
||||
id: "orphan-b",
|
||||
date: "2026-06-28",
|
||||
fingerprint: "fp",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await deleteSyncedEventsFromGoogleCalendar(ORIGIN, getAccessToken);
|
||||
|
||||
expect(deleteGoogleCalendarEvent).toHaveBeenCalledTimes(2);
|
||||
expect(result).toMatchObject({ success: true, deleted: 2, failed: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user