From 95da84ff1630b03b445df6fb65ef4b407b2a5f1f Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sat, 27 Jun 2026 15:31:12 +0930 Subject: [PATCH] fix: appease the ci godas --- jest.config.js | 1 + .../timetable/CalendarSyncControl.svelte | 32 +-- .../utils/calendarSync/lessonSyncShared.ts | 236 ++++++++++++++++++ .../utils/calendarSync/weeklySyncMessages.ts | 51 ++++ .../googleCalendar/calendarSyncListener.ts | 34 +-- src/seqta/utils/googleCalendar/syncEngine.ts | 212 +++++----------- src/seqta/utils/outlookCalendar/syncEngine.ts | 211 +++++----------- src/test/mocks/color.ts | 86 +++++++ 8 files changed, 497 insertions(+), 366 deletions(-) create mode 100644 src/seqta/utils/calendarSync/lessonSyncShared.ts create mode 100644 src/seqta/utils/calendarSync/weeklySyncMessages.ts create mode 100644 src/test/mocks/color.ts diff --git a/jest.config.js b/jest.config.js index 428e2515..6234dba9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,6 +11,7 @@ export default { }, moduleNameMapper: { '^@/(.*)$': '/src/$1', + '^color$': '/src/test/mocks/color.ts', '^webextension-polyfill$': '/src/test/mocks/webextension-polyfill.ts', }, moduleFileExtensions: ['ts', 'js', 'json'], diff --git a/src/plugins/built-in/timetable/CalendarSyncControl.svelte b/src/plugins/built-in/timetable/CalendarSyncControl.svelte index 6610929e..63f8809b 100644 --- a/src/plugins/built-in/timetable/CalendarSyncControl.svelte +++ b/src/plugins/built-in/timetable/CalendarSyncControl.svelte @@ -520,7 +520,7 @@ -
+
{/if} @@ -851,10 +846,6 @@ margin-bottom: 8px; } - .bsplus-cal-provider--outlook { - margin-bottom: 0; - } - .bsplus-cal-provider-row { display: flex; align-items: center; @@ -933,27 +924,6 @@ background: color-mix(in srgb, var(--bsplus-cal-text) 14%, var(--bsplus-cal-surface)); } - .bsplus-cal-coming-soon { - margin-top: 8px; - padding: 8px 10px; - border-radius: 8px; - border: 1px dashed var(--bsplus-cal-border, color-mix(in srgb, var(--bsplus-cal-text) 18%, transparent)); - } - - .bsplus-cal-coming-soon-label { - display: block; - font-size: 11px; - font-weight: 600; - color: color-mix(in srgb, var(--bsplus-cal-text, #111) 70%, transparent); - } - - .bsplus-cal-coming-soon-hint { - display: block; - margin-top: 2px; - font-size: 10px; - color: color-mix(in srgb, var(--bsplus-cal-text, #111) 50%, transparent); - } - .bsplus-cal-toast { position: fixed; right: 16px; diff --git a/src/seqta/utils/calendarSync/lessonSyncShared.ts b/src/seqta/utils/calendarSync/lessonSyncShared.ts new file mode 100644 index 00000000..07e3b94f --- /dev/null +++ b/src/seqta/utils/calendarSync/lessonSyncShared.ts @@ -0,0 +1,236 @@ +import { verboseLog } from "@/utils/verboseLog"; +import { + getStoredEventId, + lessonDateFromSeqtaKey, + normalizeEventMapEntry, +} from "@/seqta/utils/googleCalendar/eventMapEntry"; +import { + isDateInRange, + syncWindowRange, +} from "@/seqta/utils/googleCalendar/syncDateRange"; +import type { + GoogleCalendarSyncOptions, + GoogleCalendarSyncProgress, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; + +export const EVENT_MAP_PERSIST_EVERY = 10; + +export type EventMapRecord = Record; + +export type MappedLessonEvent = { + seqtaKey: string; + startDateTime: string; +}; + +export function reportSyncProgress( + onProgress: GoogleCalendarSyncOptions["onProgress"], + progress: GoogleCalendarSyncProgress, +) { + onProgress?.(progress); +} + +export function lessonDateForEvent(startDateTime: string, seqtaKey: string): string { + return startDateTime.slice(0, 10) || lessonDateFromSeqtaKey(seqtaKey) || ""; +} + +export function originEventMapEntries( + eventMap: EventMapRecord, + origin: string, +): Array<[string, string]> { + const prefix = `${origin}::`; + const entries: Array<[string, string]> = []; + for (const [key, value] of Object.entries(eventMap)) { + if (!key.startsWith(prefix)) continue; + const id = getStoredEventId(value); + if (id) entries.push([key, id]); + } + return entries; +} + +function shouldPruneEntry( + mode: "full" | "incremental", + entry: { id: string; date: string }, + mapKey: string, + window: ReturnType, + currentMapKeys: Set, +): boolean { + if (mode === "incremental") return false; + if (entry.date) return !isDateInRange(entry.date, window); + return !currentMapKeys.has(mapKey); +} + +export function entriesToPrune( + eventMap: EventMapRecord, + origin: string, + mode: "full" | "incremental", + weeksAhead: number, + currentMapKeys: Set, +): Array<[string, string]> { + const window = syncWindowRange(weeksAhead); + const prefix = `${origin}::`; + const entries: Array<[string, string]> = []; + + for (const [mapKey, raw] of Object.entries(eventMap)) { + if (!mapKey.startsWith(prefix)) continue; + const entry = normalizeEventMapEntry(raw); + if (!entry) continue; + if (shouldPruneEntry(mode, entry, mapKey, window, currentMapKeys)) { + entries.push([mapKey, entry.id]); + } + } + + return entries; +} + +export function notConfiguredSyncResult(error: string): GoogleCalendarSyncResult { + return { success: false, configured: false, error }; +} + +export function notConnectedSyncResult(error: string): GoogleCalendarSyncResult { + return { success: false, configured: true, connected: false, error }; +} + +export function emptyLessonsSyncResult(): GoogleCalendarSyncResult { + return { + success: false, + configured: true, + connected: true, + error: "No timetable classes found to sync for the selected range.", + }; +} + +export function buildLessonSyncResult( + created: number, + updated: number, + deleted: number, + failed: number, + lastSyncAt: number, +): GoogleCalendarSyncResult { + return { + success: failed === 0, + configured: true, + connected: true, + created, + updated, + deleted, + skipped: 0, + failed, + lastSyncAt, + error: + failed > 0 + ? `Synced with ${failed} error${failed === 1 ? "" : "s"}. Check the console for details.` + : undefined, + }; +} + +export async function persistFinalSyncState( + writeState: (patch: { + eventMap: EventMapRecord; + lastSyncAt: number; + lastSyncOrigin: string; + }) => Promise, + eventMap: EventMapRecord, + lastSyncAt: number, + origin: string, + staleDeleted: number, + staleEntryCount: number, + eventCount: number, +): Promise { + if (staleDeleted > 0 || staleEntryCount > 0 || eventCount > 0) { + await writeState({ eventMap, lastSyncAt, lastSyncOrigin: origin }); + } +} + +type UpsertLessonEventsParams = { + events: TEvent[]; + eventMap: EventMapRecord; + origin: string; + staleEntryCount: number; + totalSteps: number; + lastSyncAt: number; + initialFailed: number; + getAccessToken: () => Promise; + mapKey: (origin: string, seqtaKey: string) => string; + upsert: ( + accessToken: string, + existingId: string | undefined, + event: TEvent, + refreshAccessToken: () => Promise, + ) => Promise; + writeState: (patch: { + eventMap: EventMapRecord; + lastSyncAt: number; + lastSyncOrigin: string; + }) => Promise; + onProgress?: GoogleCalendarSyncOptions["onProgress"]; + logLabel: string; +}; + +export async function upsertLessonEvents( + params: UpsertLessonEventsParams, +): Promise<{ created: number; updated: number; failed: number; accessToken: string }> { + const { + events, + eventMap, + origin, + staleEntryCount, + totalSteps, + lastSyncAt, + initialFailed, + getAccessToken, + mapKey, + upsert, + writeState, + onProgress, + logLabel, + } = params; + + let accessToken = await getAccessToken(); + let created = 0; + let updated = 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 progressCurrent = staleEntryCount + i + 1; + const progressMessage = `Syncing events (${i + 1}/${events.length})…`; + + try { + const remoteId = await upsert(accessToken, existingId, event, async () => { + accessToken = await getAccessToken(); + return accessToken; + }); + if (existingId) updated += 1; + else created += 1; + eventMap[key] = { + id: remoteId, + date: lessonDateForEvent(event.startDateTime, event.seqtaKey), + }; + + reportSyncProgress(onProgress, { + phase: "upserting", + current: progressCurrent, + total: totalSteps, + message: progressMessage, + }); + + 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; + reportSyncProgress(onProgress, { + phase: "upserting", + current: progressCurrent, + total: totalSteps, + message: progressMessage, + }); + } + } + + return { created, updated, failed, accessToken }; +} diff --git a/src/seqta/utils/calendarSync/weeklySyncMessages.ts b/src/seqta/utils/calendarSync/weeklySyncMessages.ts new file mode 100644 index 00000000..249be3a7 --- /dev/null +++ b/src/seqta/utils/calendarSync/weeklySyncMessages.ts @@ -0,0 +1,51 @@ +import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; +import { formatSyncResultMessage } from "@/seqta/utils/googleCalendar/syncRunner"; +import { formatOutlookSyncResultMessage } from "@/seqta/utils/outlookCalendar/syncRunner"; + +type ProviderCalendarState = { + refreshToken?: string; + accessToken?: string; +}; + +function isProviderConnected(state: ProviderCalendarState): boolean { + return Boolean(state.refreshToken || state.accessToken); +} + +function providerHadChanges(result: GoogleCalendarSyncResult): boolean { + return ( + (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0 + ); +} + +export function weeklySyncErrorMessage( + results: GoogleCalendarSyncResult[], +): string | undefined { + const failed = results.find((result) => !result.success); + if (!failed) return undefined; + return failed.error ?? "Weekly calendar sync failed."; +} + +export function formatWeeklySyncMessages( + google: ProviderCalendarState, + outlook: ProviderCalendarState, + results: GoogleCalendarSyncResult[], +): string[] { + const messages: string[] = []; + let index = 0; + + if (isProviderConnected(google)) { + const result = results[index++]; + if (result && providerHadChanges(result)) { + messages.push(formatSyncResultMessage(result)); + } + } + + if (isProviderConnected(outlook)) { + const result = results[index++]; + if (result && providerHadChanges(result)) { + messages.push(formatOutlookSyncResultMessage(result)); + } + } + + return messages; +} diff --git a/src/seqta/utils/googleCalendar/calendarSyncListener.ts b/src/seqta/utils/googleCalendar/calendarSyncListener.ts index 66daf582..e4d750eb 100644 --- a/src/seqta/utils/googleCalendar/calendarSyncListener.ts +++ b/src/seqta/utils/googleCalendar/calendarSyncListener.ts @@ -4,13 +4,11 @@ import { shouldRunWeeklySync, } from "@/seqta/utils/calendarSync/settings"; import { - formatSyncResultMessage, - runGoogleCalendarSync, -} from "@/seqta/utils/googleCalendar/syncRunner"; -import { - formatOutlookSyncResultMessage, - runOutlookCalendarSync, -} from "@/seqta/utils/outlookCalendar/syncRunner"; + formatWeeklySyncMessages, + weeklySyncErrorMessage, +} from "@/seqta/utils/calendarSync/weeklySyncMessages"; +import { runGoogleCalendarSync } from "@/seqta/utils/googleCalendar/syncRunner"; +import { runOutlookCalendarSync } from "@/seqta/utils/outlookCalendar/syncRunner"; import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage"; import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; @@ -70,27 +68,13 @@ export async function maybeRunDueWeeklySync( const results = await runWeeklySyncForConnectedProviders(); if (!onComplete) return; - const errors = results.filter((r) => !r.success); - if (errors.length > 0) { - onComplete(errors[0]?.error ?? "Weekly calendar sync failed.", true); + const errorMessage = weeklySyncErrorMessage(results); + if (errorMessage) { + onComplete(errorMessage, true); return; } - const messages: string[] = []; - let index = 0; - if (google.refreshToken || google.accessToken) { - const result = results[index++]; - const changed = - (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0; - if (changed) messages.push(formatSyncResultMessage(result)); - } - if (outlook.refreshToken || outlook.accessToken) { - const result = results[index++]; - const changed = - (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0; - if (changed) messages.push(formatOutlookSyncResultMessage(result)); - } - + const messages = formatWeeklySyncMessages(google, outlook, results); if (messages.length > 0) { onComplete(messages.join(" ")); } diff --git a/src/seqta/utils/googleCalendar/syncEngine.ts b/src/seqta/utils/googleCalendar/syncEngine.ts index bbc146c2..2517043a 100644 --- a/src/seqta/utils/googleCalendar/syncEngine.ts +++ b/src/seqta/utils/googleCalendar/syncEngine.ts @@ -1,16 +1,19 @@ import { verboseLog } from "@/utils/verboseLog"; import { isGoogleCalendarConfigured } from "@/config/googleCalendar"; import { googleApiEventBody, mapLessonsToGoogleEvents } from "@/seqta/utils/googleCalendar/eventMapper"; -import { - getStoredEventId, - lessonDateFromSeqtaKey, - normalizeEventMapEntry, -} from "@/seqta/utils/googleCalendar/eventMapEntry"; -import { - isDateInRange, - syncWindowRange, -} from "@/seqta/utils/googleCalendar/syncDateRange"; import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; +import { + buildLessonSyncResult, + emptyLessonsSyncResult, + entriesToPrune, + EVENT_MAP_PERSIST_EVERY, + notConfiguredSyncResult, + notConnectedSyncResult, + originEventMapEntries, + persistFinalSyncState, + reportSyncProgress, + upsertLessonEvents, +} from "@/seqta/utils/calendarSync/lessonSyncShared"; import { eventMapKey, readGoogleCalendarState, @@ -19,7 +22,6 @@ import { import type { GoogleCalendarDeleteResult, GoogleCalendarSyncOptions, - GoogleCalendarSyncProgress, GoogleCalendarSyncRequest, GoogleCalendarSyncResult, } from "@/seqta/utils/googleCalendar/types"; @@ -28,7 +30,6 @@ import { upsertGoogleCalendarEvent, } from "@/seqta/utils/googleCalendar/upsertEvent"; -const EVENT_MAP_PERSIST_EVERY = 10; const CALENDAR_ID = "primary"; type DeleteTrackedEventsResult = { @@ -36,17 +37,6 @@ type DeleteTrackedEventsResult = { failed: number; }; -function reportProgress( - onProgress: GoogleCalendarSyncOptions["onProgress"], - progress: GoogleCalendarSyncProgress, -) { - onProgress?.(progress); -} - -function lessonDateForEvent(startDateTime: string, seqtaKey: string): string { - return startDateTime.slice(0, 10) || lessonDateFromSeqtaKey(seqtaKey) || ""; -} - async function deleteTrackedEventsFromGoogle( entries: Array<[string, string]>, eventMap: Record, @@ -72,7 +62,7 @@ async function deleteTrackedEventsFromGoogle( delete eventMap[mapKey]; deleted += 1; - reportProgress(onProgress, { + reportSyncProgress(onProgress, { phase: "deleting", current: progressOffset + deleted + failed, total: progressTotal, @@ -85,7 +75,7 @@ async function deleteTrackedEventsFromGoogle( } catch (err) { verboseLog("[BetterSEQTA+] Google Calendar event delete failed:", err); failed += 1; - reportProgress(onProgress, { + reportSyncProgress(onProgress, { phase: "deleting", current: progressOffset + deleted + failed, total: progressTotal, @@ -97,51 +87,6 @@ async function deleteTrackedEventsFromGoogle( return { deleted, failed }; } -function originEventMapEntries( - eventMap: Record, - origin: string, -): Array<[string, string]> { - const prefix = `${origin}::`; - const entries: Array<[string, string]> = []; - for (const [key, value] of Object.entries(eventMap)) { - if (!key.startsWith(prefix)) continue; - const id = getStoredEventId(value); - if (id) entries.push([key, id]); - } - return entries; -} - -function entriesToPrune( - eventMap: Record, - origin: string, - mode: "full" | "incremental", - weeksAhead: number, - currentMapKeys: Set, -): Array<[string, string]> { - const window = syncWindowRange(weeksAhead); - const prefix = `${origin}::`; - const entries: Array<[string, string]> = []; - - for (const [mapKey, raw] of Object.entries(eventMap)) { - if (!mapKey.startsWith(prefix)) continue; - const entry = normalizeEventMapEntry(raw); - if (!entry) continue; - - let shouldDelete = false; - if (mode === "incremental") { - shouldDelete = false; - } else if (entry.date) { - shouldDelete = !isDateInRange(entry.date, window); - } else { - shouldDelete = !currentMapKeys.has(mapKey); - } - - if (shouldDelete) entries.push([mapKey, entry.id]); - } - - return entries; -} - /** Runs in the content script tab so long syncs are not killed by the MV3 service worker. */ export async function syncLessonsToGoogleCalendar( request: GoogleCalendarSyncRequest, @@ -149,16 +94,14 @@ export async function syncLessonsToGoogleCalendar( options: GoogleCalendarSyncOptions = {}, ): Promise { if (!isGoogleCalendarConfigured()) { - return { - success: false, - configured: false, - error: "Google Calendar is not configured in this extension build.", - }; + return notConfiguredSyncResult( + "Google Calendar is not configured in this extension build.", + ); } const state = await readGoogleCalendarState(); if (!state.refreshToken && !state.accessToken) { - return { success: false, configured: true, connected: false, error: "Connect Google Calendar first." }; + return notConnectedSyncResult("Connect Google Calendar first."); } const mode = request.mode ?? "full"; @@ -167,26 +110,21 @@ export async function syncLessonsToGoogleCalendar( const events = mapLessonsToGoogleEvents(request.origin, request.lessons, timeZone); if (events.length === 0 && mode === "full") { - return { - success: false, - configured: true, - connected: true, - error: "No timetable classes found to sync for the selected range.", - }; + return emptyLessonsSyncResult(); } - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "preparing", current: 0, total: Math.max(events.length, 1), message: mode === "incremental" ? "Preparing weekly sync…" : "Preparing sync…", }); - let accessToken = await getAccessToken(); const eventMap = { ...(state.eventMap ?? {}) }; 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 lastSyncAt = Date.now(); const staleResult = await deleteTrackedEventsFromGoogle( staleEntries, @@ -198,89 +136,53 @@ export async function syncLessonsToGoogleCalendar( totalSteps, ); - let created = 0; - let updated = 0; - let failed = staleResult.failed; - const lastSyncAt = Date.now(); - - for (let i = 0; i < events.length; i++) { - const event = events[i]; - const mapKey = eventMapKey(request.origin, event.seqtaKey); - const existingId = getStoredEventId(eventMap[mapKey]); - try { - const googleId = await upsertGoogleCalendarEvent( + const upsertResult = await upsertLessonEvents({ + events, + eventMap, + origin: request.origin, + staleEntryCount: staleEntries.length, + totalSteps, + lastSyncAt, + initialFailed: staleResult.failed, + getAccessToken, + mapKey: eventMapKey, + upsert: (accessToken, existingId, event, refreshAccessToken) => + upsertGoogleCalendarEvent( accessToken, CALENDAR_ID, existingId, googleApiEventBody(event), - async () => { - accessToken = await getAccessToken(); - return accessToken; - }, - ); - if (existingId) updated += 1; - else created += 1; - eventMap[mapKey] = { - id: googleId, - date: lessonDateForEvent(event.startDateTime, event.seqtaKey), - }; + refreshAccessToken, + ), + writeState: writeGoogleCalendarState, + onProgress: options.onProgress, + logLabel: "Google Calendar", + }); - reportProgress(options.onProgress, { - phase: "upserting", - current: staleEntries.length + i + 1, - total: totalSteps, - message: `Syncing events (${i + 1}/${events.length})…`, - }); + await persistFinalSyncState( + writeGoogleCalendarState, + eventMap, + lastSyncAt, + request.origin, + staleResult.deleted, + staleEntries.length, + events.length, + ); - if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) { - await writeGoogleCalendarState({ - eventMap, - lastSyncAt, - lastSyncOrigin: request.origin, - }); - } - } catch (err) { - verboseLog("[BetterSEQTA+] Google Calendar event sync failed:", err); - failed += 1; - reportProgress(options.onProgress, { - phase: "upserting", - current: staleEntries.length + i + 1, - total: totalSteps, - message: `Syncing events (${i + 1}/${events.length})…`, - }); - } - } - - if (staleResult.deleted > 0 || staleEntries.length > 0 || events.length > 0) { - await writeGoogleCalendarState({ - eventMap, - lastSyncAt, - lastSyncOrigin: request.origin, - }); - } - - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "done", current: totalSteps, total: totalSteps, message: "Sync complete", }); - return { - success: failed === 0, - configured: true, - connected: true, - created, - updated, - deleted: staleResult.deleted, - skipped: 0, - failed, + return buildLessonSyncResult( + upsertResult.created, + upsertResult.updated, + staleResult.deleted, + upsertResult.failed, lastSyncAt, - error: - failed > 0 - ? `Synced with ${failed} error${failed === 1 ? "" : "s"}. Check the console for details.` - : undefined, - }; + ); } /** Delete all tracked BetterSEQTA+ events for this SEQTA origin from Google Calendar. */ @@ -307,7 +209,7 @@ export async function deleteSyncedEventsFromGoogleCalendar( return { success: true, configured: true, connected: true, deleted: 0, failed: 0 }; } - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "preparing", current: 0, total: entries.length, @@ -327,7 +229,7 @@ export async function deleteSyncedEventsFromGoogleCalendar( await writeGoogleCalendarState({ eventMap }); - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "done", current: entries.length, total: entries.length, diff --git a/src/seqta/utils/outlookCalendar/syncEngine.ts b/src/seqta/utils/outlookCalendar/syncEngine.ts index dd3c3815..345ea1dc 100644 --- a/src/seqta/utils/outlookCalendar/syncEngine.ts +++ b/src/seqta/utils/outlookCalendar/syncEngine.ts @@ -2,18 +2,20 @@ import { verboseLog } from "@/utils/verboseLog"; import { isOutlookCalendarConfigured } from "@/config/outlookCalendar"; import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; import { - getStoredEventId, - lessonDateFromSeqtaKey, - normalizeEventMapEntry, -} from "@/seqta/utils/googleCalendar/eventMapEntry"; -import { - isDateInRange, - syncWindowRange, -} from "@/seqta/utils/googleCalendar/syncDateRange"; + buildLessonSyncResult, + emptyLessonsSyncResult, + entriesToPrune, + EVENT_MAP_PERSIST_EVERY, + notConfiguredSyncResult, + notConnectedSyncResult, + originEventMapEntries, + persistFinalSyncState, + reportSyncProgress, + upsertLessonEvents, +} from "@/seqta/utils/calendarSync/lessonSyncShared"; import type { GoogleCalendarDeleteResult, GoogleCalendarSyncOptions, - GoogleCalendarSyncProgress, GoogleCalendarSyncRequest, GoogleCalendarSyncResult, } from "@/seqta/utils/googleCalendar/types"; @@ -31,24 +33,11 @@ import { upsertOutlookCalendarEvent, } from "@/seqta/utils/outlookCalendar/upsertEvent"; -const EVENT_MAP_PERSIST_EVERY = 10; - type DeleteTrackedEventsResult = { deleted: number; failed: number; }; -function reportProgress( - onProgress: GoogleCalendarSyncOptions["onProgress"], - progress: GoogleCalendarSyncProgress, -) { - onProgress?.(progress); -} - -function lessonDateForEvent(startDateTime: string, seqtaKey: string): string { - return startDateTime.slice(0, 10) || lessonDateFromSeqtaKey(seqtaKey) || ""; -} - async function deleteTrackedEventsFromOutlook( entries: Array<[string, string]>, eventMap: Record, @@ -73,7 +62,7 @@ async function deleteTrackedEventsFromOutlook( delete eventMap[mapKey]; deleted += 1; - reportProgress(onProgress, { + reportSyncProgress(onProgress, { phase: "deleting", current: progressOffset + deleted + failed, total: progressTotal, @@ -86,7 +75,7 @@ async function deleteTrackedEventsFromOutlook( } catch (err) { verboseLog("[BetterSEQTA+] Outlook Calendar event delete failed:", err); failed += 1; - reportProgress(onProgress, { + reportSyncProgress(onProgress, { phase: "deleting", current: progressOffset + deleted + failed, total: progressTotal, @@ -98,67 +87,20 @@ async function deleteTrackedEventsFromOutlook( return { deleted, failed }; } -function originEventMapEntries( - eventMap: Record, - origin: string, -): Array<[string, string]> { - const prefix = `${origin}::`; - const entries: Array<[string, string]> = []; - for (const [key, value] of Object.entries(eventMap)) { - if (!key.startsWith(prefix)) continue; - const id = getStoredEventId(value); - if (id) entries.push([key, id]); - } - return entries; -} - -function entriesToPrune( - eventMap: Record, - origin: string, - mode: "full" | "incremental", - weeksAhead: number, - currentMapKeys: Set, -): Array<[string, string]> { - const window = syncWindowRange(weeksAhead); - const prefix = `${origin}::`; - const entries: Array<[string, string]> = []; - - for (const [mapKey, raw] of Object.entries(eventMap)) { - if (!mapKey.startsWith(prefix)) continue; - const entry = normalizeEventMapEntry(raw); - if (!entry) continue; - - let shouldDelete = false; - if (mode === "incremental") { - shouldDelete = false; - } else if (entry.date) { - shouldDelete = !isDateInRange(entry.date, window); - } else { - shouldDelete = !currentMapKeys.has(mapKey); - } - - if (shouldDelete) entries.push([mapKey, entry.id]); - } - - return entries; -} - export async function syncLessonsToOutlookCalendar( request: GoogleCalendarSyncRequest, getAccessToken: () => Promise, options: GoogleCalendarSyncOptions = {}, ): Promise { if (!isOutlookCalendarConfigured()) { - return { - success: false, - configured: false, - error: "Outlook Calendar is not configured in this extension build.", - }; + return notConfiguredSyncResult( + "Outlook Calendar is not configured in this extension build.", + ); } const state = await readOutlookCalendarState(); if (!state.refreshToken && !state.accessToken) { - return { success: false, configured: true, connected: false, error: "Connect Outlook Calendar first." }; + return notConnectedSyncResult("Connect Outlook Calendar first."); } const mode = request.mode ?? "full"; @@ -167,28 +109,23 @@ export async function syncLessonsToOutlookCalendar( const events = mapLessonsToOutlookEvents(request.origin, request.lessons, timeZone); if (events.length === 0 && mode === "full") { - return { - success: false, - configured: true, - connected: true, - error: "No timetable classes found to sync for the selected range.", - }; + return emptyLessonsSyncResult(); } - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "preparing", current: 0, total: Math.max(events.length, 1), message: mode === "incremental" ? "Preparing weekly sync…" : "Preparing sync…", }); - let accessToken = await getAccessToken(); const eventMap = { ...(state.eventMap ?? {}) }; const currentMapKeys = new Set( events.map((event) => outlookEventMapKey(request.origin, event.seqtaKey)), ); const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys); const totalSteps = staleEntries.length + events.length; + const lastSyncAt = Date.now(); const staleResult = await deleteTrackedEventsFromOutlook( staleEntries, @@ -200,88 +137,52 @@ export async function syncLessonsToOutlookCalendar( totalSteps, ); - let created = 0; - let updated = 0; - let failed = staleResult.failed; - const lastSyncAt = Date.now(); - - for (let i = 0; i < events.length; i++) { - const event = events[i]; - const mapKey = outlookEventMapKey(request.origin, event.seqtaKey); - const existingId = getStoredEventId(eventMap[mapKey]); - try { - const outlookId = await upsertOutlookCalendarEvent( + const upsertResult = await upsertLessonEvents({ + events, + eventMap, + origin: request.origin, + staleEntryCount: staleEntries.length, + totalSteps, + lastSyncAt, + initialFailed: staleResult.failed, + getAccessToken, + mapKey: outlookEventMapKey, + upsert: (accessToken, existingId, event, refreshAccessToken) => + upsertOutlookCalendarEvent( accessToken, existingId, outlookGraphEventBody(event), - async () => { - accessToken = await getAccessToken(); - return accessToken; - }, - ); - if (existingId) updated += 1; - else created += 1; - eventMap[mapKey] = { - id: outlookId, - date: lessonDateForEvent(event.startDateTime, event.seqtaKey), - }; + refreshAccessToken, + ), + writeState: writeOutlookCalendarState, + onProgress: options.onProgress, + logLabel: "Outlook Calendar", + }); - reportProgress(options.onProgress, { - phase: "upserting", - current: staleEntries.length + i + 1, - total: totalSteps, - message: `Syncing events (${i + 1}/${events.length})…`, - }); + await persistFinalSyncState( + writeOutlookCalendarState, + eventMap, + lastSyncAt, + request.origin, + staleResult.deleted, + staleEntries.length, + events.length, + ); - if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) { - await writeOutlookCalendarState({ - eventMap, - lastSyncAt, - lastSyncOrigin: request.origin, - }); - } - } catch (err) { - verboseLog("[BetterSEQTA+] Outlook Calendar event sync failed:", err); - failed += 1; - reportProgress(options.onProgress, { - phase: "upserting", - current: staleEntries.length + i + 1, - total: totalSteps, - message: `Syncing events (${i + 1}/${events.length})…`, - }); - } - } - - if (staleResult.deleted > 0 || staleEntries.length > 0 || events.length > 0) { - await writeOutlookCalendarState({ - eventMap, - lastSyncAt, - lastSyncOrigin: request.origin, - }); - } - - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "done", current: totalSteps, total: totalSteps, message: "Sync complete", }); - return { - success: failed === 0, - configured: true, - connected: true, - created, - updated, - deleted: staleResult.deleted, - skipped: 0, - failed, + return buildLessonSyncResult( + upsertResult.created, + upsertResult.updated, + staleResult.deleted, + upsertResult.failed, lastSyncAt, - error: - failed > 0 - ? `Synced with ${failed} error${failed === 1 ? "" : "s"}. Check the console for details.` - : undefined, - }; + ); } export async function deleteSyncedEventsFromOutlookCalendar( @@ -307,7 +208,7 @@ export async function deleteSyncedEventsFromOutlookCalendar( return { success: true, configured: true, connected: true, deleted: 0, failed: 0 }; } - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "preparing", current: 0, total: entries.length, @@ -327,7 +228,7 @@ export async function deleteSyncedEventsFromOutlookCalendar( await writeOutlookCalendarState({ eventMap }); - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "done", current: entries.length, total: entries.length, diff --git a/src/test/mocks/color.ts b/src/test/mocks/color.ts new file mode 100644 index 00000000..585602a5 --- /dev/null +++ b/src/test/mocks/color.ts @@ -0,0 +1,86 @@ +type ColorChannels = { + r: number; + g: number; + b: number; + a: number; +}; + +function clampByte(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function clampAlpha(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function toHexByte(value: number): string { + return clampByte(value).toString(16).padStart(2, "0"); +} + +function createColor(channels: ColorChannels) { + const color = { + red: () => clampByte(channels.r), + green: () => clampByte(channels.g), + blue: () => clampByte(channels.b), + alpha: () => clampAlpha(channels.a), + hex: () => + `#${toHexByte(channels.r)}${toHexByte(channels.g)}${toHexByte(channels.b)}`, + }; + + return { + ...color, + alpha: (value?: number) => { + if (value === undefined) return clampAlpha(channels.a); + return createColor({ ...channels, a: value }); + }, + }; +} + +function parseHex(input: string): ColorChannels | null { + const short = input.match(/^#([0-9a-f]{3})$/i); + if (short) { + const [r, g, b] = short[1].split(""); + return { + r: parseInt(`${r}${r}`, 16), + g: parseInt(`${g}${g}`, 16), + b: parseInt(`${b}${b}`, 16), + a: 1, + }; + } + + const long = input.match(/^#([0-9a-f]{6})$/i); + if (!long) return null; + + return { + r: parseInt(long[1].slice(0, 2), 16), + g: parseInt(long[1].slice(2, 4), 16), + b: parseInt(long[1].slice(4, 6), 16), + a: 1, + }; +} + +function parseRgb(input: string): ColorChannels | null { + const match = input.match( + /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i, + ); + if (!match) return null; + + return { + r: Number(match[1]), + g: Number(match[2]), + b: Number(match[3]), + a: match[4] !== undefined ? Number(match[4]) : 1, + }; +} + +function Color(input: string) { + const channels = parseHex(input) ?? parseRgb(input); + if (!channels) { + throw new Error(`Unable to parse color: ${input}`); + } + return createColor(channels); +} + +Color.rgb = (r: number, g: number, b: number) => createColor({ r, g, b, a: 1 }); + +export default Color;