From 7779edb0631d8d927af78c9e8ac83dbc69e2a44e Mon Sep 17 00:00:00 2001 From: SethBurkart123 Date: Sun, 28 Jun 2026 10:45:45 +1000 Subject: [PATCH] refactor: consolidate and debloat calendar sync implementation Merge duplicated Google/Outlook engines, background handlers, and API layers into shared calendarSync modules to cut complexity without changing user-facing sync behavior. --- .env.example | 7 +- jest.config.js | 1 - src/background.ts | 9 +- src/background/calendarBackground.ts | 531 +++++++++++++ src/background/calendarWeekly.ts | 88 --- src/background/googleCalendar.ts | 363 --------- src/background/outlookCalendar.ts | 302 -------- src/config/googleCalendar.ts | 10 +- src/config/outlookCalendar.ts | 12 +- src/lib/extensionAssetUrl.ts | 21 +- .../CalendarDeleteEventsModal.svelte | 144 ---- .../timetable/CalendarDisconnectModal.svelte | 144 ---- .../timetable/CalendarSyncControl.svelte | 699 ++++++++++-------- .../timetable/CalendarSyncProgress.svelte | 67 -- .../built-in/timetable/calendarSyncHost.css | 1 - .../built-in/timetable/calendarSyncPortal.ts | 16 - .../built-in/timetable/calendarSyncTheme.ts | 88 --- .../built-in/timetable/calendarSyncUi.ts | 127 +++- src/seqta/ui/colors/parseCssColor.test.ts | 22 +- src/seqta/ui/colors/parseCssColor.ts | 35 - .../accountsToken.ts | 48 +- src/seqta/utils/calendarSync/eventMap.ts | 58 ++ .../calendarSync/lessonSyncShared.test.ts | 134 +--- .../utils/calendarSync/lessonSyncShared.ts | 237 +++--- .../utils/calendarSync/providerStorage.ts | 51 ++ src/seqta/utils/calendarSync/remoteEvents.ts | 144 ++++ src/seqta/utils/calendarSync/settings.ts | 55 +- .../utils/calendarSync/sharedSettings.ts | 48 -- src/seqta/utils/calendarSync/syncEngine.ts | 285 +++++++ src/seqta/utils/calendarSync/syncRunner.ts | 86 +++ .../utils/calendarSync/weeklySyncMessages.ts | 51 -- .../utils/googleCalendar/accountsToken.ts | 76 -- .../googleCalendar/calendarSyncListener.ts | 59 +- .../utils/googleCalendar/eventMapEntry.ts | 29 - src/seqta/utils/googleCalendar/eventMapper.ts | 18 + .../utils/googleCalendar/fetchTimetable.ts | 2 - src/seqta/utils/googleCalendar/storage.ts | 51 +- .../utils/googleCalendar/syncDateRange.ts | 31 +- .../utils/googleCalendar/syncEngine.test.ts | 21 +- src/seqta/utils/googleCalendar/syncEngine.ts | 250 ------- src/seqta/utils/googleCalendar/syncRunner.ts | 77 -- .../utils/googleCalendar/syncSettings.ts | 10 - src/seqta/utils/googleCalendar/upsertEvent.ts | 63 -- .../utils/outlookCalendar/eventMapper.ts | 36 - src/seqta/utils/outlookCalendar/storage.ts | 46 +- src/seqta/utils/outlookCalendar/syncEngine.ts | 249 ------- src/seqta/utils/outlookCalendar/syncRunner.ts | 77 -- src/seqta/utils/outlookCalendar/types.ts | 6 - .../utils/outlookCalendar/upsertEvent.test.ts | 49 +- .../utils/outlookCalendar/upsertEvent.ts | 68 -- src/test/mocks/color.ts | 86 --- src/utils/verboseLog.ts | 35 +- 52 files changed, 1965 insertions(+), 3258 deletions(-) create mode 100644 src/background/calendarBackground.ts delete mode 100644 src/background/calendarWeekly.ts delete mode 100644 src/background/googleCalendar.ts delete mode 100644 src/background/outlookCalendar.ts delete mode 100644 src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte delete mode 100644 src/plugins/built-in/timetable/CalendarDisconnectModal.svelte delete mode 100644 src/plugins/built-in/timetable/CalendarSyncProgress.svelte delete mode 100644 src/plugins/built-in/timetable/calendarSyncPortal.ts delete mode 100644 src/plugins/built-in/timetable/calendarSyncTheme.ts rename src/seqta/utils/{outlookCalendar => calendarSync}/accountsToken.ts (57%) create mode 100644 src/seqta/utils/calendarSync/eventMap.ts create mode 100644 src/seqta/utils/calendarSync/providerStorage.ts create mode 100644 src/seqta/utils/calendarSync/remoteEvents.ts delete mode 100644 src/seqta/utils/calendarSync/sharedSettings.ts create mode 100644 src/seqta/utils/calendarSync/syncEngine.ts create mode 100644 src/seqta/utils/calendarSync/syncRunner.ts delete mode 100644 src/seqta/utils/calendarSync/weeklySyncMessages.ts delete mode 100644 src/seqta/utils/googleCalendar/accountsToken.ts delete mode 100644 src/seqta/utils/googleCalendar/eventMapEntry.ts delete mode 100644 src/seqta/utils/googleCalendar/syncEngine.ts delete mode 100644 src/seqta/utils/googleCalendar/syncRunner.ts delete mode 100644 src/seqta/utils/googleCalendar/syncSettings.ts delete mode 100644 src/seqta/utils/googleCalendar/upsertEvent.ts delete mode 100644 src/seqta/utils/outlookCalendar/eventMapper.ts delete mode 100644 src/seqta/utils/outlookCalendar/syncEngine.ts delete mode 100644 src/seqta/utils/outlookCalendar/syncRunner.ts delete mode 100644 src/seqta/utils/outlookCalendar/types.ts delete mode 100644 src/seqta/utils/outlookCalendar/upsertEvent.ts delete mode 100644 src/test/mocks/color.ts diff --git a/.env.example b/.env.example index ded48c13..1bfca606 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,3 @@ -# Optional override for Google OAuth client ID at build/dev time. -# Client secret is NOT used in the extension — token exchange is on accounts.betterseqta.org. -# See docs/GOOGLE_CALENDAR_ACCOUNTS_CALLBACK.md - +# Optional OAuth client ID overrides (secrets live on accounts.betterseqta.org) # GOOGLE_OAUTH_CLIENT_ID=your-id.apps.googleusercontent.com - -# Outlook / Microsoft Graph (see docs/OUTLOOK_CALENDAR_ACCOUNTS_CALLBACK.md) # OUTLOOK_OAUTH_CLIENT_ID=your-azure-application-client-id diff --git a/jest.config.js b/jest.config.js index 6234dba9..428e2515 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,7 +11,6 @@ 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/background.ts b/src/background.ts index 67af512a..6cd7f938 100644 --- a/src/background.ts +++ b/src/background.ts @@ -13,8 +13,11 @@ import { withSuppressedCloudAutoUpload, } from "./background/cloudSettingsAutoSync"; import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl"; -import { registerGoogleCalendarMessageHandlers, initGoogleCalendarBackground } from "./background/googleCalendar"; -import { registerOutlookCalendarMessageHandlers } from "./background/outlookCalendar"; +import { initCalendarBackground } from "./background/calendarBackground"; +import { + registerGoogleCalendarMessageHandlers, + registerOutlookCalendarMessageHandlers, +} from "./background/calendarBackground"; /** * Session-only dev-mode override of the content API base. @@ -561,7 +564,7 @@ const MESSAGE_HANDLERS: Record = { registerGoogleCalendarMessageHandlers(MESSAGE_HANDLERS, isTrustedSender); registerOutlookCalendarMessageHandlers(MESSAGE_HANDLERS, isTrustedSender); -initGoogleCalendarBackground(); +initCalendarBackground(); browser.runtime.onMessage.addListener( // @ts-ignore - OnMessageListener expects literal true for async, we return boolean diff --git a/src/background/calendarBackground.ts b/src/background/calendarBackground.ts new file mode 100644 index 00000000..258e549e --- /dev/null +++ b/src/background/calendarBackground.ts @@ -0,0 +1,531 @@ +import { + GOOGLE_AUTH_URL, + GOOGLE_CALENDAR_ACCOUNTS_NOT_READY_HINT, + GOOGLE_CALENDAR_OAUTH_CALLBACK, + GOOGLE_CALENDAR_REFRESH_URL, + GOOGLE_CALENDAR_SCOPE, + GOOGLE_CALENDAR_TOKEN_URL, + GOOGLE_OAUTH_CLIENT_ID, + googleOAuthRedirectUriHint, + isGoogleCalendarConfigured, +} from "@/config/googleCalendar"; +import { + OUTLOOK_AUTH_URL, + OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT, + OUTLOOK_CALENDAR_OAUTH_CALLBACK, + OUTLOOK_CALENDAR_REFRESH_URL, + OUTLOOK_CALENDAR_SCOPE, + OUTLOOK_CALENDAR_TOKEN_URL, + OUTLOOK_OAUTH_CLIENT_ID, + isOutlookCalendarConfigured, + outlookOAuthRedirectUriHint, +} from "@/config/outlookCalendar"; +import { + exchangeAccountsCode, + refreshAccountsToken, + type AccountsTokenPayload, +} from "@/seqta/utils/calendarSync/accountsToken"; +import { + CALENDAR_WEEKLY_ALARM, + clampSyncWeeks, + getAutoSyncWeekly, + getSyncWeeksAhead, + isAnyCalendarConnected, + markWeeklySyncPending, + readSharedCalendarSyncSettings, + writeSharedCalendarSyncSettings, +} from "@/seqta/utils/calendarSync/settings"; +import { + clearGoogleCalendarState, + readGoogleCalendarState, + writeGoogleCalendarState, +} from "@/seqta/utils/googleCalendar/storage"; +import type { + GoogleCalendarStatus, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; +import { + clearOutlookCalendarState, + readOutlookCalendarState, + writeOutlookCalendarState, +} from "@/seqta/utils/outlookCalendar/storage"; +import type { OutlookCalendarStatus } from "@/seqta/utils/outlookCalendar/storage"; +import browser from "webextension-polyfill"; + +const WEEKLY_PERIOD_MINUTES = 7 * 24 * 60; + +export type CalendarMessageHandler = ( + request: unknown, + sendResponse: (response?: unknown) => void, + sender?: browser.Runtime.MessageSender, +) => boolean | void; + +export type CalendarMessageHandlerMap = Record; + +type StoredTokens = { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + lastSyncAt?: number; + lastSyncOrigin?: string; +}; + +type OAuthTabFlowOptions = { + callbackPrefix: string; + timeoutMessage: string; + cancelledMessage: string; + tabOpenErrorMessage: string; + timeoutMs?: number; +}; + +type CalendarProviderBackend = { + label: string; + isConfigured: () => boolean; + redirectHint: () => string; + mismatchRe: RegExp; + authUrl: string; + clientId: string; + scope: string; + callback: string; + tabOpts: Omit; + notConfiguredError: string; + notConnectedError: string; + signInFailed: string; + read: () => Promise; + write: (patch: Partial & { connectedAt?: number }) => Promise; + clear: () => Promise; + exchange: (code: string, redirectUri: string, verifier: string) => Promise; + refresh: (refreshToken: string) => Promise; + applyAuthParams: (authUrl: URL) => void; + messagePrefix: string; +}; + +function createProviderBackend( + config: Omit & { + tokenUrl: string; + refreshUrl: string; + notReadyHint: string; + includeErrorDescription?: boolean; + }, +): CalendarProviderBackend { + const { tokenUrl, refreshUrl, notReadyHint, includeErrorDescription, ...backend } = config; + return { + ...backend, + exchange: (code, redirectUri, verifier) => + exchangeAccountsCode(tokenUrl, code, redirectUri, verifier, notReadyHint, includeErrorDescription), + refresh: (refreshToken) => + refreshAccountsToken(refreshUrl, refreshToken, notReadyHint, includeErrorDescription), + }; +} + +const GOOGLE_BACKEND = createProviderBackend({ + label: "Google", + isConfigured: isGoogleCalendarConfigured, + redirectHint: googleOAuthRedirectUriHint, + mismatchRe: /redirect_uri_mismatch|invalid_request/i, + authUrl: GOOGLE_AUTH_URL, + clientId: GOOGLE_OAUTH_CLIENT_ID, + scope: GOOGLE_CALENDAR_SCOPE, + callback: GOOGLE_CALENDAR_OAUTH_CALLBACK, + tabOpts: { + timeoutMessage: "Google sign-in timed out. Close the tab and try again.", + cancelledMessage: "Google sign-in was cancelled.", + tabOpenErrorMessage: "Could not open Google sign-in tab.", + }, + notConfiguredError: "Google Calendar is not configured in this extension build.", + notConnectedError: "Not connected to Google Calendar.", + signInFailed: "Google sign-in failed", + read: readGoogleCalendarState, + write: writeGoogleCalendarState, + clear: clearGoogleCalendarState, + applyAuthParams(authUrl) { + authUrl.searchParams.set("access_type", "offline"); + }, + messagePrefix: "googleCalendar", + tokenUrl: GOOGLE_CALENDAR_TOKEN_URL, + refreshUrl: GOOGLE_CALENDAR_REFRESH_URL, + notReadyHint: GOOGLE_CALENDAR_ACCOUNTS_NOT_READY_HINT, +}); + +const OUTLOOK_BACKEND = createProviderBackend({ + label: "Microsoft", + isConfigured: isOutlookCalendarConfigured, + redirectHint: outlookOAuthRedirectUriHint, + mismatchRe: /redirect_uri|invalid_request|AADSTS50011/i, + authUrl: OUTLOOK_AUTH_URL, + clientId: OUTLOOK_OAUTH_CLIENT_ID, + scope: OUTLOOK_CALENDAR_SCOPE, + callback: OUTLOOK_CALENDAR_OAUTH_CALLBACK, + tabOpts: { + timeoutMessage: "Microsoft sign-in timed out. Close the tab and try again.", + cancelledMessage: "Microsoft sign-in was cancelled.", + tabOpenErrorMessage: "Could not open Microsoft sign-in tab.", + }, + notConfiguredError: "Outlook Calendar is not configured in this extension build.", + notConnectedError: "Not connected to Outlook Calendar.", + signInFailed: "Microsoft sign-in failed", + read: readOutlookCalendarState, + write: writeOutlookCalendarState, + clear: clearOutlookCalendarState, + applyAuthParams(authUrl) { + authUrl.searchParams.set("response_mode", "query"); + }, + messagePrefix: "outlookCalendar", + tokenUrl: OUTLOOK_CALENDAR_TOKEN_URL, + refreshUrl: OUTLOOK_CALENDAR_REFRESH_URL, + notReadyHint: OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT, + includeErrorDescription: true, +}); + +function base64UrlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function randomPkceVerifier(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes.buffer); +} + +async function pkceChallenge(verifier: string): Promise { + const data = new TextEncoder().encode(verifier); + const digest = await crypto.subtle.digest("SHA-256", data); + return base64UrlEncode(digest); +} + +function parseOAuthRedirectCode(responseUrl: string, providerLabel: string): string { + const url = new URL(responseUrl); + const err = url.searchParams.get("error"); + if (err) throw new Error(url.searchParams.get("error_description") ?? err); + const code = url.searchParams.get("code"); + if (!code) throw new Error(`${providerLabel} sign-in did not return an authorization code.`); + return code; +} + +function isOAuthCallbackUrl(url: string, callbackPrefix: string): boolean { + if (!url.startsWith(callbackPrefix)) return false; + const parsed = new URL(url); + return parsed.searchParams.has("code") || parsed.searchParams.has("error"); +} + +function waitForOAuthCallback(authTabId: number, opts: OAuthTabFlowOptions): Promise { + const timeoutMs = opts.timeoutMs ?? 10 * 60 * 1000; + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + cleanup(); + reject(new Error(opts.timeoutMessage)); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeoutId); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.tabs.onRemoved.removeListener(onRemoved); + }; + + const finishFromUrl = (url: string, tabId: number) => { + if (!isOAuthCallbackUrl(url, opts.callbackPrefix)) return false; + cleanup(); + void browser.tabs.remove(tabId).catch(() => {}); + resolve(url); + return true; + }; + + const onRemoved = (tabId: number) => { + if (tabId !== authTabId) return; + cleanup(); + reject(new Error(opts.cancelledMessage)); + }; + + const onUpdated = ( + tabId: number, + changeInfo: browser.Tabs.OnUpdatedChangeInfoType, + tab: browser.Tabs.Tab, + ) => { + if (tabId !== authTabId) return; + const url = + changeInfo.url ?? (changeInfo.status === "complete" ? tab.url : undefined); + if (url) finishFromUrl(url, tabId); + }; + + browser.tabs.onUpdated.addListener(onUpdated); + browser.tabs.onRemoved.addListener(onRemoved); + + void browser.tabs + .get(authTabId) + .then((tab) => { + if (tab.url) finishFromUrl(tab.url, authTabId); + }) + .catch(() => {}); + }); +} + +async function openOAuthTab(authUrl: string, opts: OAuthTabFlowOptions): Promise { + const tab = await browser.tabs.create({ url: authUrl, active: true }); + if (tab.id === undefined) throw new Error(opts.tabOpenErrorMessage); + return waitForOAuthCallback(tab.id, opts); +} + +function tokenExpiresAt(expiresIn?: number): number { + return expiresIn ? Date.now() + expiresIn * 1000 : Date.now() + 3_600_000; +} + +async function getValidStoredAccessToken( + provider: CalendarProviderBackend, +): Promise { + const state = await provider.read(); + const now = Date.now(); + if (state.accessToken && state.expiresAt && state.expiresAt > now + 60_000) { + return state.accessToken; + } + if (!state.refreshToken) throw new Error(provider.notConnectedError); + const refreshed = await provider.refresh(state.refreshToken); + await provider.write({ + accessToken: refreshed.access_token, + refreshToken: refreshed.refresh_token ?? state.refreshToken, + expiresAt: tokenExpiresAt(refreshed.expires_in), + }); + return refreshed.access_token; +} + +export function registerTrustedAsyncHandler( + handlers: CalendarMessageHandlerMap, + isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean, + key: string, + fn: (request: unknown) => Promise, + onError?: (err: unknown) => unknown, +): void { + handlers[key] = (request, sendResponse, sender) => { + if (!isTrustedSender(sender)) { + sendResponse({ success: false, error: "Unauthorized sender" }); + return false; + } + void fn(request) + .then(sendResponse) + .catch((err) => { + sendResponse( + onError?.(err) ?? { + success: false, + error: err instanceof Error ? err.message : "Request failed", + }, + ); + }); + return true; + }; +} + +async function getGoogleCalendarStatus(): Promise { + const [state, shared, syncWeeksAhead, autoSyncWeekly] = await Promise.all([ + readGoogleCalendarState(), + readSharedCalendarSyncSettings(), + getSyncWeeksAhead(), + getAutoSyncWeekly(), + ]); + return { + configured: isGoogleCalendarConfigured(), + connected: !!(state.refreshToken || state.accessToken), + lastSyncAt: state.lastSyncAt, + lastWeeklySyncAt: shared.lastWeeklySyncAt, + lastSyncOrigin: state.lastSyncOrigin, + syncWeeksAhead, + autoSyncWeekly, + }; +} + +async function getOutlookCalendarStatus(): Promise { + const state = await readOutlookCalendarState(); + return { + configured: isOutlookCalendarConfigured(), + connected: !!(state.refreshToken || state.accessToken), + lastSyncAt: state.lastSyncAt, + lastSyncOrigin: state.lastSyncOrigin, + }; +} + +async function connectCalendar(provider: CalendarProviderBackend): Promise { + if (!provider.isConfigured()) { + return { success: false, configured: false, error: provider.notConfiguredError }; + } + + const redirectUri = provider.callback; + const verifier = randomPkceVerifier(); + const challenge = await pkceChallenge(verifier); + const authUrl = new URL(provider.authUrl); + authUrl.searchParams.set("client_id", provider.clientId); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("scope", provider.scope); + authUrl.searchParams.set("prompt", "consent"); + authUrl.searchParams.set("code_challenge", challenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + provider.applyAuthParams(authUrl); + + let responseUrl: string; + try { + responseUrl = await openOAuthTab(authUrl.toString(), { + callbackPrefix: provider.callback, + ...provider.tabOpts, + }); + } catch (err) { + const message = err instanceof Error ? err.message : provider.signInFailed; + const mismatch = + provider.mismatchRe.test(message) || provider.mismatchRe.test(String(err)); + return { + success: false, + configured: true, + error: mismatch + ? `${provider.label} redirect URI mismatch. ${provider.redirectHint()}` + : message.includes("cancel") + ? provider.tabOpts.cancelledMessage + : message, + }; + } + + try { + const code = parseOAuthRedirectCode(responseUrl, provider.label); + const tokens = await provider.exchange(code, redirectUri, verifier); + const existing = await provider.read(); + await provider.write({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? existing.refreshToken, + expiresAt: tokenExpiresAt(tokens.expires_in), + connectedAt: Date.now(), + }); + await ensureWeeklySyncAlarm(); + return { success: true, configured: true, connected: true }; + } catch (err) { + return { + success: false, + configured: true, + error: err instanceof Error ? err.message : provider.signInFailed, + }; + } +} + +function registerProviderHandlers( + handlers: CalendarMessageHandlerMap, + isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean, + provider: CalendarProviderBackend, + getStatus: () => Promise, +): void { + const { messagePrefix, signInFailed } = provider; + const errMsg = (err: unknown, fallback: string) => + err instanceof Error ? err.message : fallback; + + registerTrustedAsyncHandler( + handlers, + isTrustedSender, + `${messagePrefix}Connect`, + () => connectCalendar(provider), + (err) => ({ success: false, error: errMsg(err, signInFailed) }), + ); + + registerTrustedAsyncHandler( + handlers, + isTrustedSender, + `${messagePrefix}Disconnect`, + async () => { + await provider.clear(); + await ensureWeeklySyncAlarm(); + return { success: true }; + }, + (err) => ({ success: false, error: errMsg(err, "Disconnect failed") }), + ); + + registerTrustedAsyncHandler( + handlers, + isTrustedSender, + `${messagePrefix}Status`, + getStatus, + () => ({ configured: provider.isConfigured(), connected: false }), + ); + + registerTrustedAsyncHandler( + handlers, + isTrustedSender, + `${messagePrefix}GetAccessToken`, + () => getValidStoredAccessToken(provider).then((accessToken) => ({ success: true, accessToken })), + (err) => ({ success: false, error: errMsg(err, "Token refresh failed") }), + ); +} + +export function registerGoogleCalendarMessageHandlers( + handlers: CalendarMessageHandlerMap, + isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean, +): void { + registerProviderHandlers(handlers, isTrustedSender, GOOGLE_BACKEND, getGoogleCalendarStatus); + + registerTrustedAsyncHandler(handlers, isTrustedSender, "googleCalendarEnsureWeeklyAlarm", async () => { + await ensureWeeklySyncAlarm(); + return { success: true }; + }, (err) => ({ + success: false, + error: err instanceof Error ? err.message : "Could not schedule weekly sync", + })); + + registerTrustedAsyncHandler(handlers, isTrustedSender, "googleCalendarUpdateSyncSettings", async (request) => { + const body = request as { syncWeeksAhead?: number; autoSyncWeekly?: boolean }; + const patch: Record = {}; + if (body.syncWeeksAhead != null) patch.syncWeeksAhead = clampSyncWeeks(body.syncWeeksAhead); + if (body.autoSyncWeekly != null) patch.autoSyncWeekly = !!body.autoSyncWeekly; + if (Object.keys(patch).length > 0) await writeSharedCalendarSyncSettings(patch); + await ensureWeeklySyncAlarm(); + return { success: true, ...(await getGoogleCalendarStatus()) }; + }, (err) => ({ + success: false, + error: err instanceof Error ? err.message : "Could not update sync settings", + })); +} + +export function registerOutlookCalendarMessageHandlers( + handlers: CalendarMessageHandlerMap, + isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean, +): void { + registerProviderHandlers(handlers, isTrustedSender, OUTLOOK_BACKEND, getOutlookCalendarStatus); +} + +function isSeqtaTab(tab: browser.Tabs.Tab): boolean { + const title = tab.title ?? ""; + return title.includes("SEQTA Learn") || title.includes("SEQTA Engage"); +} + +export async function ensureWeeklySyncAlarm(): Promise { + const [connected, enabled] = await Promise.all([isAnyCalendarConnected(), getAutoSyncWeekly()]); + if (!connected || !enabled) { + await browser.alarms.clear(CALENDAR_WEEKLY_ALARM); + return; + } + const existing = await browser.alarms.get(CALENDAR_WEEKLY_ALARM); + if (!existing) { + await browser.alarms.create(CALENDAR_WEEKLY_ALARM, { periodInMinutes: WEEKLY_PERIOD_MINUTES }); + } +} + +async function triggerWeeklySyncOnSeqtaTabs(): Promise { + const tabs = await browser.tabs.query({}); + let delivered = false; + for (const tab of tabs) { + if (tab.id == null || !isSeqtaTab(tab)) continue; + try { + await browser.tabs.sendMessage(tab.id, { type: "calendarRunWeeklySync" }); + delivered = true; + } catch { + // Tab may not have content script yet. + } + } + return delivered; +} + +async function handleWeeklySyncAlarm(): Promise { + if (!(await isAnyCalendarConnected()) || !(await getAutoSyncWeekly())) return; + if (!(await triggerWeeklySyncOnSeqtaTabs())) await markWeeklySyncPending(); +} + +export function initCalendarBackground(): void { + browser.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === CALENDAR_WEEKLY_ALARM) void handleWeeklySyncAlarm(); + }); + void ensureWeeklySyncAlarm(); +} diff --git a/src/background/calendarWeekly.ts b/src/background/calendarWeekly.ts deleted file mode 100644 index 9a7d2090..00000000 --- a/src/background/calendarWeekly.ts +++ /dev/null @@ -1,88 +0,0 @@ -import browser from "webextension-polyfill"; -import { verboseLog } from "@/utils/verboseLog"; -import { - CALENDAR_WEEKLY_ALARM, - getAutoSyncWeekly, - markWeeklySyncPending, -} from "@/seqta/utils/calendarSync/settings"; -import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; -import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage"; - -const WEEKLY_PERIOD_MINUTES = 7 * 24 * 60; - -function isSeqtaTab(tab: browser.Tabs.Tab): boolean { - const title = tab.title ?? ""; - return title.includes("SEQTA Learn") || title.includes("SEQTA Engage"); -} - -async function isAnyCalendarConnected(): Promise { - const [google, outlook] = await Promise.all([ - readGoogleCalendarState(), - readOutlookCalendarState(), - ]); - return !!( - google.refreshToken || - google.accessToken || - outlook.refreshToken || - outlook.accessToken - ); -} - -export async function ensureWeeklySyncAlarm(): Promise { - const connected = await isAnyCalendarConnected(); - const enabled = await getAutoSyncWeekly(); - if (!connected || !enabled) { - await browser.alarms.clear(CALENDAR_WEEKLY_ALARM); - return; - } - - const existing = await browser.alarms.get(CALENDAR_WEEKLY_ALARM); - if (!existing) { - await browser.alarms.create(CALENDAR_WEEKLY_ALARM, { - periodInMinutes: WEEKLY_PERIOD_MINUTES, - }); - } -} - -export async function clearWeeklySyncAlarm(): Promise { - await browser.alarms.clear(CALENDAR_WEEKLY_ALARM); -} - -export async function triggerWeeklySyncOnSeqtaTabs(): Promise { - const tabs = await browser.tabs.query({}); - const seqtaTabs = tabs.filter((tab) => tab.id != null && isSeqtaTab(tab)); - if (seqtaTabs.length === 0) return false; - - let delivered = false; - for (const tab of seqtaTabs) { - if (tab.id == null) continue; - try { - await browser.tabs.sendMessage(tab.id, { type: "calendarRunWeeklySync" }); - delivered = true; - } catch (err) { - verboseLog("[BetterSEQTA+] Weekly calendar sync message failed for tab:", tab.id, err); - } - } - return delivered; -} - -export async function handleWeeklySyncAlarm(): Promise { - if (!(await isAnyCalendarConnected()) || !(await getAutoSyncWeekly())) return; - - const delivered = await triggerWeeklySyncOnSeqtaTabs(); - if (!delivered) { - await markWeeklySyncPending(); - } -} - -export function registerWeeklySyncAlarmListener(): void { - browser.alarms.onAlarm.addListener((alarm) => { - if (alarm.name !== CALENDAR_WEEKLY_ALARM) return; - void handleWeeklySyncAlarm(); - }); -} - -export function initCalendarBackground(): void { - registerWeeklySyncAlarmListener(); - void ensureWeeklySyncAlarm(); -} diff --git a/src/background/googleCalendar.ts b/src/background/googleCalendar.ts deleted file mode 100644 index 9042e855..00000000 --- a/src/background/googleCalendar.ts +++ /dev/null @@ -1,363 +0,0 @@ -import browser from "webextension-polyfill"; -import { - GOOGLE_AUTH_URL, - GOOGLE_CALENDAR_OAUTH_CALLBACK, - GOOGLE_CALENDAR_SCOPE, - GOOGLE_OAUTH_CLIENT_ID, - googleOAuthRedirectUriHint, - isGoogleCalendarConfigured, -} from "@/config/googleCalendar"; -import { - exchangeGoogleCodeViaAccounts, - refreshGoogleTokenViaAccounts, -} from "@/seqta/utils/googleCalendar/accountsToken"; -import { - clearGoogleCalendarState, - readGoogleCalendarState, - writeGoogleCalendarState, -} from "@/seqta/utils/googleCalendar/storage"; -import { - clampSyncWeeks, - getAutoSyncWeekly, - getSyncWeeksAhead, -} from "@/seqta/utils/calendarSync/settings"; -import { - readSharedCalendarSyncSettings, - writeSharedCalendarSyncSettings, -} from "@/seqta/utils/calendarSync/sharedSettings"; -import type { - GoogleCalendarStatus, - GoogleCalendarSyncResult, -} from "@/seqta/utils/googleCalendar/types"; -import { ensureWeeklySyncAlarm, initCalendarBackground } from "./calendarWeekly"; - -function base64UrlEncode(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (const b of bytes) binary += String.fromCharCode(b); - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); -} - -async function sha256(input: string): Promise { - const data = new TextEncoder().encode(input); - return crypto.subtle.digest("SHA-256", data); -} - -function randomVerifier(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return base64UrlEncode(bytes.buffer); -} - -async function pkceChallenge(verifier: string): Promise { - return base64UrlEncode(await sha256(verifier)); -} - -function parseRedirectCode(responseUrl: string): string { - const url = new URL(responseUrl); - const err = url.searchParams.get("error"); - if (err) throw new Error(url.searchParams.get("error_description") ?? err); - const code = url.searchParams.get("code"); - if (!code) throw new Error("Google sign-in did not return an authorization code."); - return code; -} - -const GOOGLE_OAUTH_CALLBACK_PREFIX = GOOGLE_CALENDAR_OAUTH_CALLBACK; -const GOOGLE_OAUTH_TIMEOUT_MS = 10 * 60 * 1000; - -function isGoogleOAuthCallbackUrl(url: string): boolean { - if (!url.startsWith(GOOGLE_OAUTH_CALLBACK_PREFIX)) return false; - const parsed = new URL(url); - return parsed.searchParams.has("code") || parsed.searchParams.has("error"); -} - -/** Tab-based OAuth (same pattern as cloud login) — launchWebAuthFlow does not reliably return external redirect URLs. */ -function waitForGoogleOAuthCallback(authTabId: number): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - cleanup(); - reject(new Error("Google sign-in timed out. Close the tab and try again.")); - }, GOOGLE_OAUTH_TIMEOUT_MS); - - const cleanup = () => { - clearTimeout(timeoutId); - browser.tabs.onUpdated.removeListener(onUpdated); - browser.tabs.onRemoved.removeListener(onRemoved); - }; - - const finishFromUrl = (url: string, tabId: number) => { - if (!isGoogleOAuthCallbackUrl(url)) return false; - cleanup(); - void browser.tabs.remove(tabId).catch(() => {}); - resolve(url); - return true; - }; - - const onRemoved = (tabId: number) => { - if (tabId !== authTabId) return; - cleanup(); - reject(new Error("Google sign-in was cancelled.")); - }; - - const onUpdated = ( - tabId: number, - changeInfo: browser.Tabs.OnUpdatedChangeInfoType, - tab: browser.Tabs.Tab, - ) => { - if (tabId !== authTabId) return; - const url = - changeInfo.url ?? (changeInfo.status === "complete" ? tab.url : undefined); - if (url) finishFromUrl(url, tabId); - }; - - browser.tabs.onUpdated.addListener(onUpdated); - browser.tabs.onRemoved.addListener(onRemoved); - - void browser.tabs - .get(authTabId) - .then((tab) => { - if (tab.url) finishFromUrl(tab.url, authTabId); - }) - .catch(() => {}); - }); -} - -async function openGoogleOAuthTab(authUrl: string): Promise { - const tab = await browser.tabs.create({ url: authUrl, active: true }); - if (tab.id === undefined) { - throw new Error("Could not open Google sign-in tab."); - } - return waitForGoogleOAuthCallback(tab.id); -} - -async function getValidAccessToken(): Promise { - const state = await readGoogleCalendarState(); - const now = Date.now(); - if (state.accessToken && state.expiresAt && state.expiresAt > now + 60_000) { - return state.accessToken; - } - if (!state.refreshToken) { - throw new Error("Not connected to Google Calendar."); - } - const refreshed = await refreshGoogleTokenViaAccounts(state.refreshToken); - const expiresAt = refreshed.expires_in - ? Date.now() + refreshed.expires_in * 1000 - : Date.now() + 3_600_000; - await writeGoogleCalendarState({ - accessToken: refreshed.access_token, - refreshToken: refreshed.refresh_token ?? state.refreshToken, - expiresAt, - }); - return refreshed.access_token; -} - -async function connectGoogleCalendar(): Promise { - if (!isGoogleCalendarConfigured()) { - return { - success: false, - configured: false, - error: "Google Calendar is not configured in this extension build.", - }; - } - - const redirectUri = GOOGLE_CALENDAR_OAUTH_CALLBACK; - const verifier = randomVerifier(); - const challenge = await pkceChallenge(verifier); - const authUrl = new URL(GOOGLE_AUTH_URL); - authUrl.searchParams.set("client_id", GOOGLE_OAUTH_CLIENT_ID); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("redirect_uri", redirectUri); - authUrl.searchParams.set("scope", GOOGLE_CALENDAR_SCOPE); - authUrl.searchParams.set("access_type", "offline"); - authUrl.searchParams.set("prompt", "consent"); - authUrl.searchParams.set("code_challenge", challenge); - authUrl.searchParams.set("code_challenge_method", "S256"); - - let responseUrl: string; - try { - responseUrl = await openGoogleOAuthTab(authUrl.toString()); - } catch (err) { - const message = err instanceof Error ? err.message : "Google sign-in failed"; - const mismatch = - /redirect_uri_mismatch|invalid_request/i.test(message) || - /redirect_uri_mismatch|invalid_request/i.test(String(err)); - return { - success: false, - configured: true, - error: mismatch - ? `Google redirect URI mismatch. ${googleOAuthRedirectUriHint()}` - : message.includes("cancel") - ? "Google sign-in was cancelled." - : message, - }; - } - - try { - const code = parseRedirectCode(responseUrl); - const tokens = await exchangeGoogleCodeViaAccounts(code, redirectUri, verifier); - const expiresAt = tokens.expires_in - ? Date.now() + tokens.expires_in * 1000 - : Date.now() + 3_600_000; - - const existing = await readGoogleCalendarState(); - await writeGoogleCalendarState({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token ?? existing.refreshToken, - expiresAt, - connectedAt: Date.now(), - }); - - await ensureWeeklySyncAlarm(); - - return { success: true, configured: true, connected: true }; - } catch (err) { - return { - success: false, - configured: true, - error: err instanceof Error ? err.message : "Google sign-in failed", - }; - } -} - -async function getGoogleCalendarStatus(): Promise { - const state = await readGoogleCalendarState(); - const shared = await readSharedCalendarSyncSettings(); - const syncWeeksAhead = await getSyncWeeksAhead(); - const autoSyncWeekly = await getAutoSyncWeekly(); - return { - configured: isGoogleCalendarConfigured(), - connected: !!(state.refreshToken || state.accessToken), - lastSyncAt: state.lastSyncAt, - lastWeeklySyncAt: shared.lastWeeklySyncAt, - lastSyncOrigin: state.lastSyncOrigin, - syncWeeksAhead, - autoSyncWeekly, - }; -} - -export async function handleGoogleCalendarConnect(): Promise { - return connectGoogleCalendar(); -} - -export async function handleGoogleCalendarDisconnect(): Promise<{ success: boolean }> { - await clearGoogleCalendarState(); - await ensureWeeklySyncAlarm(); - return { success: true }; -} - -export async function handleGoogleCalendarStatus(): Promise { - return getGoogleCalendarStatus(); -} - -export function registerGoogleCalendarMessageHandlers( - handlers: Record< - string, - ( - request: unknown, - sendResponse: (response?: unknown) => void, - sender?: browser.Runtime.MessageSender, - ) => boolean | void - >, - isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean, -): void { - const rejectUntrusted = ( - sendResponse: (response?: unknown) => void, - sender?: browser.Runtime.MessageSender, - ): boolean => { - if (isTrustedSender(sender)) return false; - sendResponse({ success: false, error: "Unauthorized sender" }); - return true; - }; - - handlers.googleCalendarConnect = (_req, sendResponse, sender) => { - if (rejectUntrusted(sendResponse, sender)) return false; - void handleGoogleCalendarConnect() - .then(sendResponse) - .catch((err) => { - sendResponse({ - success: false, - error: err instanceof Error ? err.message : "Google sign-in failed", - }); - }); - return true; - }; - - handlers.googleCalendarDisconnect = (_req, sendResponse, sender) => { - if (rejectUntrusted(sendResponse, sender)) return false; - void handleGoogleCalendarDisconnect() - .then(sendResponse) - .catch((err) => { - sendResponse({ - success: false, - error: err instanceof Error ? err.message : "Disconnect failed", - }); - }); - return true; - }; - - handlers.googleCalendarStatus = (_req, sendResponse, sender) => { - if (rejectUntrusted(sendResponse, sender)) return false; - void handleGoogleCalendarStatus() - .then(sendResponse) - .catch(() => { - sendResponse({ configured: isGoogleCalendarConfigured(), connected: false }); - }); - return true; - }; - - handlers.googleCalendarGetAccessToken = (_req, sendResponse, sender) => { - if (rejectUntrusted(sendResponse, sender)) return false; - void getValidAccessToken() - .then((accessToken) => sendResponse({ success: true, accessToken })) - .catch((err) => { - sendResponse({ - success: false, - error: err instanceof Error ? err.message : "Token refresh failed", - }); - }); - return true; - }; - - handlers.googleCalendarEnsureWeeklyAlarm = (_req, sendResponse, sender) => { - if (rejectUntrusted(sendResponse, sender)) return false; - void ensureWeeklySyncAlarm() - .then(() => sendResponse({ success: true })) - .catch((err) => { - sendResponse({ - success: false, - error: err instanceof Error ? err.message : "Could not schedule weekly sync", - }); - }); - return true; - }; - - handlers.googleCalendarUpdateSyncSettings = (request, sendResponse, sender) => { - if (rejectUntrusted(sendResponse, sender)) return false; - void (async () => { - const body = request as { - syncWeeksAhead?: number; - autoSyncWeekly?: boolean; - }; - const patch: Record = {}; - if (body.syncWeeksAhead != null) { - patch.syncWeeksAhead = clampSyncWeeks(body.syncWeeksAhead); - } - if (body.autoSyncWeekly != null) { - patch.autoSyncWeekly = !!body.autoSyncWeekly; - } - if (Object.keys(patch).length > 0) { - await writeSharedCalendarSyncSettings(patch); - } - await ensureWeeklySyncAlarm(); - sendResponse({ success: true, ...(await getGoogleCalendarStatus()) }); - })().catch((err) => { - sendResponse({ - success: false, - error: err instanceof Error ? err.message : "Could not update sync settings", - }); - }); - return true; - }; -} - -export { initCalendarBackground as initGoogleCalendarBackground } from "./calendarWeekly"; diff --git a/src/background/outlookCalendar.ts b/src/background/outlookCalendar.ts deleted file mode 100644 index bbbd96c8..00000000 --- a/src/background/outlookCalendar.ts +++ /dev/null @@ -1,302 +0,0 @@ -import browser from "webextension-polyfill"; -import { - OUTLOOK_AUTH_URL, - OUTLOOK_CALENDAR_OAUTH_CALLBACK, - OUTLOOK_CALENDAR_SCOPE, - OUTLOOK_OAUTH_CLIENT_ID, - isOutlookCalendarConfigured, - outlookOAuthRedirectUriHint, -} from "@/config/outlookCalendar"; -import { - exchangeOutlookCodeViaAccounts, - refreshOutlookTokenViaAccounts, -} from "@/seqta/utils/outlookCalendar/accountsToken"; -import { - clearOutlookCalendarState, - readOutlookCalendarState, - writeOutlookCalendarState, -} from "@/seqta/utils/outlookCalendar/storage"; -import type { OutlookCalendarStatus } from "@/seqta/utils/outlookCalendar/types"; -import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; -import { ensureWeeklySyncAlarm } from "./calendarWeekly"; - -function base64UrlEncode(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (const b of bytes) binary += String.fromCharCode(b); - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); -} - -async function sha256(input: string): Promise { - const data = new TextEncoder().encode(input); - return crypto.subtle.digest("SHA-256", data); -} - -function randomVerifier(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return base64UrlEncode(bytes.buffer); -} - -async function pkceChallenge(verifier: string): Promise { - return base64UrlEncode(await sha256(verifier)); -} - -function parseRedirectCode(responseUrl: string): string { - const url = new URL(responseUrl); - const err = url.searchParams.get("error"); - if (err) throw new Error(url.searchParams.get("error_description") ?? err); - const code = url.searchParams.get("code"); - if (!code) throw new Error("Microsoft sign-in did not return an authorization code."); - return code; -} - -const OAUTH_CALLBACK_PREFIX = OUTLOOK_CALENDAR_OAUTH_CALLBACK; -const OAUTH_TIMEOUT_MS = 10 * 60 * 1000; - -function isOAuthCallbackUrl(url: string): boolean { - if (!url.startsWith(OAUTH_CALLBACK_PREFIX)) return false; - const parsed = new URL(url); - return parsed.searchParams.has("code") || parsed.searchParams.has("error"); -} - -function waitForOAuthCallback(authTabId: number): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - cleanup(); - reject(new Error("Microsoft sign-in timed out. Close the tab and try again.")); - }, OAUTH_TIMEOUT_MS); - - const cleanup = () => { - clearTimeout(timeoutId); - browser.tabs.onUpdated.removeListener(onUpdated); - browser.tabs.onRemoved.removeListener(onRemoved); - }; - - const finishFromUrl = (url: string, tabId: number) => { - if (!isOAuthCallbackUrl(url)) return false; - cleanup(); - void browser.tabs.remove(tabId).catch(() => {}); - resolve(url); - return true; - }; - - const onRemoved = (tabId: number) => { - if (tabId !== authTabId) return; - cleanup(); - reject(new Error("Microsoft sign-in was cancelled.")); - }; - - const onUpdated = ( - tabId: number, - changeInfo: browser.Tabs.OnUpdatedChangeInfoType, - tab: browser.Tabs.Tab, - ) => { - if (tabId !== authTabId) return; - const url = - changeInfo.url ?? (changeInfo.status === "complete" ? tab.url : undefined); - if (url) finishFromUrl(url, tabId); - }; - - browser.tabs.onUpdated.addListener(onUpdated); - browser.tabs.onRemoved.addListener(onRemoved); - - void browser.tabs - .get(authTabId) - .then((tab) => { - if (tab.url) finishFromUrl(tab.url, authTabId); - }) - .catch(() => {}); - }); -} - -async function openOAuthTab(authUrl: string): Promise { - const tab = await browser.tabs.create({ url: authUrl, active: true }); - if (tab.id === undefined) { - throw new Error("Could not open Microsoft sign-in tab."); - } - return waitForOAuthCallback(tab.id); -} - -async function getValidAccessToken(): Promise { - const state = await readOutlookCalendarState(); - const now = Date.now(); - if (state.accessToken && state.expiresAt && state.expiresAt > now + 60_000) { - return state.accessToken; - } - if (!state.refreshToken) { - throw new Error("Not connected to Outlook Calendar."); - } - const refreshed = await refreshOutlookTokenViaAccounts(state.refreshToken); - const expiresAt = refreshed.expires_in - ? Date.now() + refreshed.expires_in * 1000 - : Date.now() + 3_600_000; - await writeOutlookCalendarState({ - accessToken: refreshed.access_token, - refreshToken: refreshed.refresh_token ?? state.refreshToken, - expiresAt, - }); - return refreshed.access_token; -} - -async function connectOutlookCalendar(): Promise { - if (!isOutlookCalendarConfigured()) { - return { - success: false, - configured: false, - error: "Outlook Calendar is not configured in this extension build.", - }; - } - - const redirectUri = OUTLOOK_CALENDAR_OAUTH_CALLBACK; - const verifier = randomVerifier(); - const challenge = await pkceChallenge(verifier); - const authUrl = new URL(OUTLOOK_AUTH_URL); - authUrl.searchParams.set("client_id", OUTLOOK_OAUTH_CLIENT_ID); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("redirect_uri", redirectUri); - authUrl.searchParams.set("scope", OUTLOOK_CALENDAR_SCOPE); - authUrl.searchParams.set("response_mode", "query"); - authUrl.searchParams.set("prompt", "consent"); - authUrl.searchParams.set("code_challenge", challenge); - authUrl.searchParams.set("code_challenge_method", "S256"); - - let responseUrl: string; - try { - responseUrl = await openOAuthTab(authUrl.toString()); - } catch (err) { - const message = err instanceof Error ? err.message : "Microsoft sign-in failed"; - const mismatch = - /redirect_uri|invalid_request|AADSTS50011/i.test(message) || - /redirect_uri|invalid_request|AADSTS50011/i.test(String(err)); - return { - success: false, - configured: true, - error: mismatch - ? `Microsoft redirect URI mismatch. ${outlookOAuthRedirectUriHint()}` - : message.includes("cancel") - ? "Microsoft sign-in was cancelled." - : message, - }; - } - - try { - const code = parseRedirectCode(responseUrl); - const tokens = await exchangeOutlookCodeViaAccounts(code, redirectUri, verifier); - const expiresAt = tokens.expires_in - ? Date.now() + tokens.expires_in * 1000 - : Date.now() + 3_600_000; - - const existing = await readOutlookCalendarState(); - await writeOutlookCalendarState({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token ?? existing.refreshToken, - expiresAt, - connectedAt: Date.now(), - }); - - await ensureWeeklySyncAlarm(); - - return { success: true, configured: true, connected: true }; - } catch (err) { - return { - success: false, - configured: true, - error: err instanceof Error ? err.message : "Microsoft sign-in failed", - }; - } -} - -async function getOutlookCalendarStatus(): Promise { - const state = await readOutlookCalendarState(); - return { - configured: isOutlookCalendarConfigured(), - connected: !!(state.refreshToken || state.accessToken), - lastSyncAt: state.lastSyncAt, - lastSyncOrigin: state.lastSyncOrigin, - }; -} - -export async function handleOutlookCalendarConnect(): Promise { - return connectOutlookCalendar(); -} - -export async function handleOutlookCalendarDisconnect(): Promise<{ success: boolean }> { - await clearOutlookCalendarState(); - await ensureWeeklySyncAlarm(); - return { success: true }; -} - -export async function handleOutlookCalendarStatus(): Promise { - return getOutlookCalendarStatus(); -} - -export function registerOutlookCalendarMessageHandlers( - handlers: Record< - string, - ( - request: unknown, - sendResponse: (response?: unknown) => void, - sender?: browser.Runtime.MessageSender, - ) => boolean | void - >, - isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean, -): void { - const rejectUntrusted = ( - sendResponse: (response?: unknown) => void, - sender?: browser.Runtime.MessageSender, - ): boolean => { - if (isTrustedSender(sender)) return false; - sendResponse({ success: false, error: "Unauthorized sender" }); - return true; - }; - - handlers.outlookCalendarConnect = (_req, sendResponse, sender) => { - if (rejectUntrusted(sendResponse, sender)) return false; - void handleOutlookCalendarConnect() - .then(sendResponse) - .catch((err) => { - sendResponse({ - success: false, - error: err instanceof Error ? err.message : "Microsoft sign-in failed", - }); - }); - return true; - }; - - handlers.outlookCalendarDisconnect = (_req, sendResponse, sender) => { - if (rejectUntrusted(sendResponse, sender)) return false; - void handleOutlookCalendarDisconnect() - .then(sendResponse) - .catch((err) => { - sendResponse({ - success: false, - error: err instanceof Error ? err.message : "Disconnect failed", - }); - }); - return true; - }; - - handlers.outlookCalendarStatus = (_req, sendResponse, sender) => { - if (rejectUntrusted(sendResponse, sender)) return false; - void handleOutlookCalendarStatus() - .then(sendResponse) - .catch(() => { - sendResponse({ configured: isOutlookCalendarConfigured(), connected: false }); - }); - return true; - }; - - handlers.outlookCalendarGetAccessToken = (_req, sendResponse, sender) => { - if (rejectUntrusted(sendResponse, sender)) return false; - void getValidAccessToken() - .then((accessToken) => sendResponse({ success: true, accessToken })) - .catch((err) => { - sendResponse({ - success: false, - error: err instanceof Error ? err.message : "Token refresh failed", - }); - }); - return true; - }; -} diff --git a/src/config/googleCalendar.ts b/src/config/googleCalendar.ts index 9b669dc7..6f33d107 100644 --- a/src/config/googleCalendar.ts +++ b/src/config/googleCalendar.ts @@ -1,8 +1,4 @@ -/** - * Google Calendar OAuth — public client config (extension). - * Client secret and token exchange live on accounts.betterseqta.org. - * See docs/GOOGLE_CALENDAR_ACCOUNTS_CALLBACK.md - */ +/** Google Calendar OAuth — public client config. Token exchange is on accounts.betterseqta.org. */ const HARDCODED_GOOGLE_OAUTH_CLIENT_ID = "270834969641-f6t7jtpu6j0cemse8updj3rkos7nl0hf.apps.googleusercontent.com"; @@ -15,9 +11,7 @@ export const GOOGLE_OAUTH_CLIENT_ID: string = export const ACCOUNTS_BASE = "https://accounts.betterseqta.org"; -/** Must match Google Console + accounts callback route exactly. */ export const GOOGLE_CALENDAR_OAUTH_CALLBACK = `${ACCOUNTS_BASE}/auth/google/calendar/callback`; - export const GOOGLE_CALENDAR_TOKEN_URL = `${ACCOUNTS_BASE}/api/bsplus/google/calendar/token`; export const GOOGLE_CALENDAR_REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/google/calendar/refresh`; @@ -28,9 +22,7 @@ export const GOOGLE_CALENDAR_API = "https://www.googleapis.com/calendar/v3"; export const BSPLUS_GOOGLE_CALENDAR_EVENT_PROP = "bsplusSeqtaKey"; -/** Default weeks of timetable to sync forward (from start of current week). */ export const GOOGLE_CALENDAR_SYNC_WEEKS = 12; -export const GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT = GOOGLE_CALENDAR_SYNC_WEEKS; export const GOOGLE_CALENDAR_SYNC_WEEKS_MIN = 1; export const GOOGLE_CALENDAR_SYNC_WEEKS_MAX = 52; diff --git a/src/config/outlookCalendar.ts b/src/config/outlookCalendar.ts index bac80183..52d959aa 100644 --- a/src/config/outlookCalendar.ts +++ b/src/config/outlookCalendar.ts @@ -1,13 +1,8 @@ -/** - * Outlook Calendar OAuth — public client config (extension). - * Client secret and token exchange live on accounts.betterseqta.org. - * See docs/OUTLOOK_CALENDAR_ACCOUNTS_CALLBACK.md - */ +/** Outlook Calendar OAuth — public client config. Token exchange is on accounts.betterseqta.org. */ import { ACCOUNTS_BASE } from "@/config/googleCalendar"; -const HARDCODED_OUTLOOK_OAUTH_CLIENT_ID = - "0b55168c-916c-4323-8f67-b3dd30af3c9e"; +const HARDCODED_OUTLOOK_OAUTH_CLIENT_ID = "0b55168c-916c-4323-8f67-b3dd30af3c9e"; const envClientId = typeof __OUTLOOK_OAUTH_CLIENT_ID__ !== "undefined" ? __OUTLOOK_OAUTH_CLIENT_ID__ : ""; @@ -15,13 +10,10 @@ const envClientId = export const OUTLOOK_OAUTH_CLIENT_ID: string = envClientId.trim() || HARDCODED_OUTLOOK_OAUTH_CLIENT_ID.trim(); -/** Must match Azure app registration + accounts callback route exactly. */ export const OUTLOOK_CALENDAR_OAUTH_CALLBACK = `${ACCOUNTS_BASE}/auth/microsoft/calendar/callback`; - export const OUTLOOK_CALENDAR_TOKEN_URL = `${ACCOUNTS_BASE}/api/bsplus/microsoft/calendar/token`; export const OUTLOOK_CALENDAR_REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/microsoft/calendar/refresh`; -/** Delegated Graph scopes for create/update/delete calendar events. */ export const OUTLOOK_CALENDAR_SCOPE = "offline_access Calendars.ReadWrite User.Read"; export const OUTLOOK_AUTH_URL = diff --git a/src/lib/extensionAssetUrl.ts b/src/lib/extensionAssetUrl.ts index 159edf58..095599ef 100644 --- a/src/lib/extensionAssetUrl.ts +++ b/src/lib/extensionAssetUrl.ts @@ -1,18 +1,9 @@ import browser from "webextension-polyfill"; -/** Vite asset imports are often already absolute extension URLs in production bundles. */ -export function resolveExtensionAssetUrl(importedUrl: string): string { - if (!importedUrl) return importedUrl; - - const doublePrefix = importedUrl.match( - /^((?:chrome|moz)-extension:\/\/[^/]+)\/\1\/(.+)$/, - ); - if (doublePrefix) { - return `${doublePrefix[1]}/${doublePrefix[2]}`; - } - - if (/^(chrome-extension|moz-extension|https?|data):/.test(importedUrl)) { - return importedUrl; - } - return browser.runtime.getURL(importedUrl.replace(/^\/+/, "")); +/** Resolve Vite asset imports to a usable extension URL. */ +export function resolveExtensionAssetUrl(url: string): string { + const doubled = url.match(/^((?:chrome|moz)-extension:\/\/[^/]+)\/\1\/(.+)$/); + if (doubled) return `${doubled[1]}/${doubled[2]}`; + if (/^(?:chrome|moz)-extension:\/\/|https?:|data:/.test(url)) return url; + return browser.runtime.getURL(url.replace(/^\/+/, "")); } diff --git a/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte b/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte deleted file mode 100644 index 1f344d6d..00000000 --- a/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte +++ /dev/null @@ -1,144 +0,0 @@ - - -{#if open} -
{ - if (e.target === e.currentTarget && !busy) onCancel(); - }} - onkeydown={(e) => { - if (e.key === "Escape" && !busy) onCancel(); - }} - role="presentation" - transition:fade={{ duration: 150 }} - > - -
-{/if} - - diff --git a/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte b/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte deleted file mode 100644 index 08429fde..00000000 --- a/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte +++ /dev/null @@ -1,144 +0,0 @@ - - -{#if open} -
{ - if (e.target === e.currentTarget && !busy) onCancel(); - }} - onkeydown={(e) => { - if (e.key === "Escape" && !busy) onCancel(); - }} - role="presentation" - transition:fade={{ duration: 150 }} - > - -
-{/if} - - diff --git a/src/plugins/built-in/timetable/CalendarSyncControl.svelte b/src/plugins/built-in/timetable/CalendarSyncControl.svelte index f175e320..642687e9 100644 --- a/src/plugins/built-in/timetable/CalendarSyncControl.svelte +++ b/src/plugins/built-in/timetable/CalendarSyncControl.svelte @@ -1,5 +1,6 @@ +{#snippet providerPanel( + provider: CalendarProvider, + status: ProviderStatus, + notConfiguredMsg: string, + icon: Snippet, + name: Snippet, +)} +
+
+ +
+ {@render name()} + + {providerStatusText(status, notConfiguredMsg)} + +
+
+ +
+ {#if !status.connected} + + {:else} + + + + {/if} +
+
+{/snippet} +
- {:else} - - - - {/if} -
- - -
-
- -
- Outlook Calendar - - {#if !outlookStatus.configured} - Set OUTLOOK_OAUTH_CLIENT_ID to enable - {:else if outlookStatus.connected} - Connected{formatLastSync(outlookStatus.lastSyncAt) ? ` · ${formatLastSync(outlookStatus.lastSyncAt)}` : ""} - {:else} - Not connected - {/if} - -
-
- -
- {#if !outlookStatus.connected} - - {:else} - - - - {/if} -
-
+ {@render providerPanel( + "google", + googleStatus, + "Not available in this build", + googleIcon, + googleName, + )} + {@render providerPanel( + "outlook", + outlookStatus, + "Set OUTLOOK_OAUTH_CLIENT_ID to enable", + outlookIcon, + outlookName, + )} {#if anyConnected}
- - -
- {/if} + + + + {/if} {/if} - { - if (busy?.phase !== "delete") showDeleteEvents = false; - }} - onConfirm={confirmDeleteEvents} - /> - - { - if (busy?.phase !== "disconnect") showDisconnect = false; - }} - onConfirm={confirmDisconnect} - /> + {#if modalOpen} +
{ + if (e.target === e.currentTarget && !modalBusy) closeModal(); + }} + onkeydown={(e) => { + if (e.key === "Escape" && !modalBusy) closeModal(); + }} + role="presentation" + transition:fade={{ duration: 150 }} + > + +
+ {/if} {#if toast}
+{#snippet googleIcon()} + + + + + + +{/snippet} + +{#snippet googleName()} + Google Calendar +{/snippet} + +{#snippet outlookIcon()} + +{/snippet} + +{#snippet outlookName()} + Outlook Calendar +{/snippet} + diff --git a/src/plugins/built-in/timetable/calendarSyncHost.css b/src/plugins/built-in/timetable/calendarSyncHost.css index 7abf0873..e0e3da77 100644 --- a/src/plugins/built-in/timetable/calendarSyncHost.css +++ b/src/plugins/built-in/timetable/calendarSyncHost.css @@ -25,5 +25,4 @@ .bsplus-calendar-sync-mount { display: inline-flex; - font-family: var(--bsplus-cal-font-family, var(--betterseqta-font-family, Rubik), sans-serif); } diff --git a/src/plugins/built-in/timetable/calendarSyncPortal.ts b/src/plugins/built-in/timetable/calendarSyncPortal.ts deleted file mode 100644 index 6b596776..00000000 --- a/src/plugins/built-in/timetable/calendarSyncPortal.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** Layer order: toolbar boost < menu < modal (all portaled UI uses the upper layers). */ -export const CALENDAR_SYNC_Z_MENU = 2_147_483_646; -export const CALENDAR_SYNC_Z_MODAL = 2_147_483_647; - -export function portalToBody(node: HTMLElement) { - document.body.appendChild(node); - return { - destroy() { - node.remove(); - }, - }; -} - -export function isCalendarSyncModalTarget(target: EventTarget | null): boolean { - return target instanceof Element && Boolean(target.closest(".bsplus-cal-modal-backdrop")); -} diff --git a/src/plugins/built-in/timetable/calendarSyncTheme.ts b/src/plugins/built-in/timetable/calendarSyncTheme.ts deleted file mode 100644 index a8320b20..00000000 --- a/src/plugins/built-in/timetable/calendarSyncTheme.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { settingsState } from "@/seqta/utils/listeners/SettingsState"; -import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor"; -import { ensureFontLoaded } from "@/seqta/ui/fonts/Manager"; -import { getFontPreset } from "@/seqta/ui/fonts/presets"; - -export const CALENDAR_THEME_CSS_VARS = [ - "--better-main", - "--better-pale", - "--better-light", - "--text-color", - "--background-primary", - "--background-secondary", - "--text-primary", - "--theme-offset-bg", - "--better-sub", -] as const; - -const ACCENT_CSS_VARS = [ - "--better-main", - "--accent-color-value", - "--accentColor", - "--colour-betterseqta-blue", -] as const; - -export function isCalendarDarkMode(): boolean { - return !!settingsState.DarkMode || document.documentElement.classList.contains("dark"); -} - -function resolvePageAccentColor(): string { - const computed = getComputedStyle(document.documentElement); - for (const name of ACCENT_CSS_VARS) { - const solid = extractSolidColor(computed.getPropertyValue(name)); - if (solid) return solid; - } - const fromSettings = settingsState.selectedColor?.trim(); - if (fromSettings) { - const solid = extractSolidColor(fromSettings); - if (solid) return solid; - } - return "#3b82f6"; -} - -/** Sync extension theme (including dark mode) onto a calendar UI host or portaled menu. */ -export function syncCalendarSyncTheme(target: HTMLElement): void { - const computed = getComputedStyle(document.documentElement); - const dark = isCalendarDarkMode(); - const fontPreset = getFontPreset(settingsState.selectedFont); - - ensureFontLoaded(fontPreset); - target.style.setProperty("--bsplus-cal-font-family", fontPreset.stack); - - for (const name of CALENDAR_THEME_CSS_VARS) { - const value = - document.documentElement.style.getPropertyValue(name).trim() || - computed.getPropertyValue(name).trim(); - if (value) target.style.setProperty(name, value); - } - - const accent = resolvePageAccentColor(); - target.style.setProperty("--bsplus-cal-accent", accent); - target.style.setProperty("--better-main", accent); - target.classList.toggle("dark", dark); - - const textPrimary = - computed.getPropertyValue("--text-primary").trim() || - computed.getPropertyValue("--text-color").trim(); - const bgPrimary = - computed.getPropertyValue("--background-primary").trim() || - computed.getPropertyValue("--background-secondary").trim() || - computed.getPropertyValue("--theme-offset-bg").trim(); - - target.style.setProperty( - "--bsplus-cal-text", - textPrimary || (dark ? "#f4f4f5" : "#18181b"), - ); - target.style.setProperty( - "--bsplus-cal-surface", - bgPrimary || (dark ? "#27272a" : "#ffffff"), - ); - target.style.setProperty( - "--bsplus-cal-surface-muted", - dark ? "#3f3f46" : "color-mix(in srgb, var(--bsplus-cal-text) 5%, var(--bsplus-cal-surface))", - ); - target.style.setProperty( - "--bsplus-cal-border", - dark ? "color-mix(in srgb, #ffffff 14%, transparent)" : "color-mix(in srgb, var(--bsplus-cal-text) 12%, transparent)", - ); -} diff --git a/src/plugins/built-in/timetable/calendarSyncUi.ts b/src/plugins/built-in/timetable/calendarSyncUi.ts index bb39c2a6..d327d78a 100644 --- a/src/plugins/built-in/timetable/calendarSyncUi.ts +++ b/src/plugins/built-in/timetable/calendarSyncUi.ts @@ -1,14 +1,133 @@ import { mount, unmount } from "svelte"; +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; +import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor"; +import { ensureFontLoaded } from "@/seqta/ui/fonts/Manager"; +import { getFontPreset } from "@/seqta/ui/fonts/presets"; +import type { GoogleCalendarSyncProgress } from "@/seqta/utils/googleCalendar/types"; import CalendarSyncControl from "./CalendarSyncControl.svelte"; -import { syncCalendarSyncTheme } from "./calendarSyncTheme"; import { registerCalendarContentHandlers } from "@/seqta/utils/googleCalendar/calendarSyncListener"; import hostStyles from "./calendarSyncHost.css?inline"; +export type CalendarProvider = "google" | "outlook"; + +export function providerLabel(provider: CalendarProvider): string { + return provider === "google" ? "Google" : "Outlook"; +} + +export function formatLastSync(ts?: number): string | null { + if (!ts) return null; + const diff = Date.now() - ts; + if (diff < 60_000) return "Synced just now"; + if (diff < 3_600_000) return `Synced ${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `Synced ${Math.floor(diff / 3_600_000)}h ago`; + return `Synced ${new Date(ts).toLocaleDateString()}`; +} + +export function syncProgressPercent(progress: GoogleCalendarSyncProgress | null): number { + if (!progress || progress.phase === "done") return 0; + if (progress.total > 0) { + return Math.min(100, Math.round((progress.current / progress.total) * 100)); + } + return progress.phase === "preparing" ? 8 : 0; +} + const CONTROLS_CLASS = "timetable-calendar-controls"; const HOST_STYLE_ID = "bsplus-calendar-sync-host-styles"; +const THEME_CSS_VARS = [ + "--better-main", + "--better-pale", + "--better-light", + "--text-color", + "--background-primary", + "--background-secondary", + "--text-primary", + "--theme-offset-bg", + "--better-sub", +] as const; + +const ACCENT_CSS_VARS = [ + "--better-main", + "--accent-color-value", + "--accentColor", + "--colour-betterseqta-blue", +] as const; + let currentApp: ReturnType | null = null; -let mountRoot: HTMLElement | null = null; + +export function portalToBody(node: HTMLElement) { + document.body.appendChild(node); + return { + destroy() { + node.remove(); + }, + }; +} + +export function isCalendarSyncModalTarget(target: EventTarget | null): boolean { + return target instanceof Element && Boolean(target.closest(".bsplus-cal-modal-backdrop")); +} + +/** Sync extension theme (including dark mode) onto a calendar UI host or portaled menu. */ +export function syncCalendarSyncTheme(target: HTMLElement): void { + const computed = getComputedStyle(document.documentElement); + const dark = !!settingsState.DarkMode || document.documentElement.classList.contains("dark"); + const fontPreset = getFontPreset(settingsState.selectedFont); + + ensureFontLoaded(fontPreset); + target.style.setProperty("--bsplus-cal-font-family", fontPreset.stack); + + for (const name of THEME_CSS_VARS) { + const value = + document.documentElement.style.getPropertyValue(name).trim() || + computed.getPropertyValue(name).trim(); + if (value) target.style.setProperty(name, value); + } + + let accent = "#3b82f6"; + for (const name of ACCENT_CSS_VARS) { + const solid = extractSolidColor(computed.getPropertyValue(name)); + if (solid) { + accent = solid; + break; + } + } + if (accent === "#3b82f6") { + const fromSettings = settingsState.selectedColor?.trim(); + if (fromSettings) { + const solid = extractSolidColor(fromSettings); + if (solid) accent = solid; + } + } + target.style.setProperty("--bsplus-cal-accent", accent); + target.style.setProperty("--better-main", accent); + target.classList.toggle("dark", dark); + + const textPrimary = + computed.getPropertyValue("--text-primary").trim() || + computed.getPropertyValue("--text-color").trim(); + const bgPrimary = + computed.getPropertyValue("--background-primary").trim() || + computed.getPropertyValue("--background-secondary").trim() || + computed.getPropertyValue("--theme-offset-bg").trim(); + + target.style.setProperty( + "--bsplus-cal-text", + textPrimary || (dark ? "#f4f4f5" : "#18181b"), + ); + target.style.setProperty( + "--bsplus-cal-surface", + bgPrimary || (dark ? "#27272a" : "#ffffff"), + ); + target.style.setProperty( + "--bsplus-cal-surface-muted", + dark ? "#3f3f46" : "color-mix(in srgb, var(--bsplus-cal-text) 5%, var(--bsplus-cal-surface))", + ); + target.style.setProperty( + "--bsplus-cal-border", + dark ? "color-mix(in srgb, #ffffff 14%, transparent)" : "color-mix(in srgb, var(--bsplus-cal-text) 12%, transparent)", + ); +} function ensureHostStyles() { if (document.getElementById(HOST_STYLE_ID)) return; @@ -23,8 +142,6 @@ function teardown() { unmount(currentApp); currentApp = null; } - - mountRoot = null; document.querySelector(`.${CONTROLS_CLASS}`)?.remove(); document.getElementById(HOST_STYLE_ID)?.remove(); } @@ -42,7 +159,7 @@ export async function mountGoogleCalendarButton(): Promise { controls.className = `${CONTROLS_CLASS} bsplus-timetable-control`; toolbar.appendChild(controls); - mountRoot = document.createElement("div"); + const mountRoot = document.createElement("div"); mountRoot.className = "bsplus-calendar-sync-mount"; syncCalendarSyncTheme(mountRoot); controls.appendChild(mountRoot); diff --git a/src/seqta/ui/colors/parseCssColor.test.ts b/src/seqta/ui/colors/parseCssColor.test.ts index ae0dbc0b..62b2f637 100644 --- a/src/seqta/ui/colors/parseCssColor.test.ts +++ b/src/seqta/ui/colors/parseCssColor.test.ts @@ -1,8 +1,4 @@ -import { - extractSolidColor, - normalizeCssColorString, - parseCssColor, -} from "./parseCssColor"; +import { extractSolidColor, normalizeCssColorString } from "./parseCssColor"; describe("normalizeCssColorString", () => { it("lowercases uppercase RGBA/RGB function names", () => { @@ -28,19 +24,3 @@ describe("extractSolidColor", () => { ).toBe("rgba(201,61,0,1)"); }); }); - -describe("parseCssColor", () => { - it("parses uppercase RGBA without throwing", () => { - const parsed = parseCssColor("RGBA(3, 29, 11, 0.58)"); - expect(parsed.alpha()).toBeCloseTo(0.58, 2); - expect(parsed.red()).toBe(3); - expect(parsed.green()).toBe(29); - expect(parsed.blue()).toBe(11); - }); - - it("falls back when the value is not a colour", () => { - expect(parseCssColor("not-a-color", "#007bff").hex().toLowerCase()).toBe( - "#007bff", - ); - }); -}); diff --git a/src/seqta/ui/colors/parseCssColor.ts b/src/seqta/ui/colors/parseCssColor.ts index d2835f94..2ba50bb6 100644 --- a/src/seqta/ui/colors/parseCssColor.ts +++ b/src/seqta/ui/colors/parseCssColor.ts @@ -1,7 +1,3 @@ -import Color from "color"; - -type ColorInstance = ReturnType; - /** * SEQTA themes and user gradients often use uppercase `RGBA()` / `RGB()`. * The `color` package only accepts lowercase function names. @@ -32,34 +28,3 @@ export function extractSolidColor(value: string): string | null { } return null; } - -/** Parse a CSS colour for the `color` library; never throws. */ -export function parseCssColor(value: string, fallback = "#007bff"): ColorInstance { - const candidates = [ - extractSolidColor(value), - normalizeCssColorString(value), - ].filter((candidate): candidate is string => Boolean(candidate)); - - for (const candidate of candidates) { - try { - return Color(candidate); - } catch { - // try next strategy - } - - const rgbaMatch = candidate.match( - /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i, - ); - if (rgbaMatch) { - try { - const [, r, g, b, a] = rgbaMatch; - const rgb = Color.rgb(Number(r), Number(g), Number(b)); - return a !== undefined ? rgb.alpha(Number(a)) : rgb; - } catch { - // fall through - } - } - } - - return Color(fallback); -} diff --git a/src/seqta/utils/outlookCalendar/accountsToken.ts b/src/seqta/utils/calendarSync/accountsToken.ts similarity index 57% rename from src/seqta/utils/outlookCalendar/accountsToken.ts rename to src/seqta/utils/calendarSync/accountsToken.ts index 7a57e916..aa7b834d 100644 --- a/src/seqta/utils/outlookCalendar/accountsToken.ts +++ b/src/seqta/utils/calendarSync/accountsToken.ts @@ -1,10 +1,4 @@ -import { - OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT, - OUTLOOK_CALENDAR_REFRESH_URL, - OUTLOOK_CALENDAR_TOKEN_URL, -} from "@/config/outlookCalendar"; - -type OutlookTokenPayload = { +export type AccountsTokenPayload = { access_token: string; refresh_token?: string; expires_in?: number; @@ -19,7 +13,7 @@ async function parseAccountsJson(res: Response): Promise } } -function extractTokens(json: Record): OutlookTokenPayload { +function extractTokens(json: Record): AccountsTokenPayload { const access_token = json.access_token; if (typeof access_token !== "string" || !access_token) { throw new Error("Token response missing access_token"); @@ -31,21 +25,30 @@ function extractTokens(json: Record): OutlookTokenPayload { }; } -function formatAccountsTokenError(res: Response, json: Record): string { - if (res.status === 404 || res.status === 501) { - return OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT; - } +function formatAccountsTokenError( + res: Response, + json: Record, + notReadyHint: string, + includeErrorDescription: boolean, +): string { + if (res.status === 404 || res.status === 501) return notReadyHint; const err = typeof json.error === "string" ? json.error : ""; - const desc = typeof json.error_description === "string" ? json.error_description : ""; + const desc = + includeErrorDescription && typeof json.error_description === "string" + ? json.error_description + : ""; return desc || err || `Accounts token API failed (${res.status})`; } -export async function exchangeOutlookCodeViaAccounts( +export async function exchangeAccountsCode( + tokenUrl: string, code: string, redirectUri: string, codeVerifier: string, -): Promise { - const res = await fetch(OUTLOOK_CALENDAR_TOKEN_URL, { + notReadyHint: string, + includeErrorDescription = false, +): Promise { + const res = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -56,22 +59,25 @@ export async function exchangeOutlookCodeViaAccounts( }); const json = await parseAccountsJson(res); if (!res.ok) { - throw new Error(formatAccountsTokenError(res, json)); + throw new Error(formatAccountsTokenError(res, json, notReadyHint, includeErrorDescription)); } return extractTokens(json); } -export async function refreshOutlookTokenViaAccounts( +export async function refreshAccountsToken( + refreshUrl: string, refreshToken: string, -): Promise { - const res = await fetch(OUTLOOK_CALENDAR_REFRESH_URL, { + notReadyHint: string, + includeErrorDescription = false, +): Promise { + const res = await fetch(refreshUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }), }); const json = await parseAccountsJson(res); if (!res.ok) { - throw new Error(formatAccountsTokenError(res, json)); + throw new Error(formatAccountsTokenError(res, json, notReadyHint, includeErrorDescription)); } return extractTokens(json); } diff --git a/src/seqta/utils/calendarSync/eventMap.ts b/src/seqta/utils/calendarSync/eventMap.ts new file mode 100644 index 00000000..b83415ae --- /dev/null +++ b/src/seqta/utils/calendarSync/eventMap.ts @@ -0,0 +1,58 @@ +import browser from "webextension-polyfill"; + +export interface EventMapEntry { + id: string; + date: string; +} + +export type EventMapRecord = Record; + +export function eventMapKey(origin: string, seqtaKey: string): string { + return `${origin}::${seqtaKey}`; +} + +export function normalizeEventMapEntry( + value: string | EventMapEntry | undefined, +): EventMapEntry | undefined { + if (value == null) return undefined; + if (typeof value === "string") return { id: value, date: "" }; + if (typeof value.id === "string" && value.id.length > 0) { + return { id: value.id, date: value.date ?? "" }; + } + return undefined; +} + +export function getStoredEventId( + value: string | EventMapEntry | undefined, +): string | undefined { + return normalizeEventMapEntry(value)?.id; +} + +export function lessonDateFromSeqtaKey(seqtaKey: string): string | undefined { + for (const part of seqtaKey.split(":")) { + if (/^\d{4}-\d{2}-\d{2}$/.test(part)) return part; + } + return undefined; +} + +export function createCalendarStateStorage(storageKey: string) { + async function read(): Promise { + const got = await browser.storage.local.get(storageKey); + const raw = got[storageKey]; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {} as T; + return raw as T; + } + + async function write(patch: Partial): Promise { + const current = await read(); + const next = { ...current, ...patch }; + await browser.storage.local.set({ [storageKey]: next }); + return next; + } + + async function clear(): Promise { + await browser.storage.local.remove(storageKey); + } + + return { read, write, clear }; +} diff --git a/src/seqta/utils/calendarSync/lessonSyncShared.test.ts b/src/seqta/utils/calendarSync/lessonSyncShared.test.ts index 2286ab64..49dc997a 100644 --- a/src/seqta/utils/calendarSync/lessonSyncShared.test.ts +++ b/src/seqta/utils/calendarSync/lessonSyncShared.test.ts @@ -1,138 +1,26 @@ -import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { describe, expect, it, jest } from "@jest/globals"; jest.mock("@/utils/verboseLog", () => ({ verboseLog: jest.fn(), })); -import { - reportSyncProgress, - resetSyncProgressThrottle, - SYNC_PROGRESS_THROTTLE_MS, -} from "./lessonSyncShared"; +import { reportSyncProgress } from "./lessonSyncShared"; import type { GoogleCalendarSyncProgress } from "@/seqta/utils/googleCalendar/types"; describe("reportSyncProgress", () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date("2026-06-28T12:00:00.000Z")); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it("reports preparing and done immediately", () => { + it("calls onProgress when provided", () => { const onProgress = jest.fn(); - const preparing: GoogleCalendarSyncProgress = { - phase: "preparing", - current: 0, - total: 10, - message: "Preparing…", - }; - const done: GoogleCalendarSyncProgress = { - phase: "done", - current: 10, - total: 10, - message: "Done", + const progress: GoogleCalendarSyncProgress = { + phase: "upserting", + current: 1, + total: 5, + message: "Syncing events (1/5)…", }; - reportSyncProgress(onProgress, preparing); - reportSyncProgress(onProgress, done); - - expect(onProgress).toHaveBeenCalledTimes(2); - expect(onProgress).toHaveBeenNthCalledWith(1, preparing); - expect(onProgress).toHaveBeenNthCalledWith(2, done); - }); - - it("throttles upserting progress to at most once per second", () => { - const onProgress = jest.fn(); - - for (let i = 1; i <= 5; i++) { - reportSyncProgress(onProgress, { - phase: "upserting", - current: i, - total: 5, - message: `Syncing events (${i}/5)…`, - }); - } - - expect(onProgress).toHaveBeenCalledTimes(1); - expect(onProgress).toHaveBeenCalledWith({ - phase: "upserting", - current: 1, - total: 5, - message: "Syncing events (1/5)…", - }); - - jest.advanceTimersByTime(SYNC_PROGRESS_THROTTLE_MS); - - expect(onProgress).toHaveBeenCalledTimes(2); - expect(onProgress).toHaveBeenLastCalledWith({ - phase: "upserting", - current: 5, - total: 5, - message: "Syncing events (5/5)…", - }); - }); - - it("flushes pending progress before reporting done", () => { - const onProgress = jest.fn(); - - reportSyncProgress(onProgress, { - phase: "upserting", - current: 1, - total: 5, - message: "Syncing events (1/5)…", - }); - - reportSyncProgress(onProgress, { - phase: "upserting", - current: 4, - total: 5, - message: "Syncing events (4/5)…", - }); - - reportSyncProgress(onProgress, { - phase: "done", - current: 5, - total: 5, - message: "Sync complete", - }); - - expect(onProgress).toHaveBeenCalledTimes(3); - expect(onProgress).toHaveBeenNthCalledWith(1, { - phase: "upserting", - current: 1, - total: 5, - message: "Syncing events (1/5)…", - }); - expect(onProgress).toHaveBeenNthCalledWith(2, { - phase: "upserting", - current: 4, - total: 5, - message: "Syncing events (4/5)…", - }); - expect(onProgress).toHaveBeenNthCalledWith(3, { - phase: "done", - current: 5, - total: 5, - message: "Sync complete", - }); - }); - - it("resetSyncProgressThrottle clears queued updates", () => { - const onProgress = jest.fn(); - - reportSyncProgress(onProgress, { - phase: "upserting", - current: 1, - total: 3, - message: "Syncing events (1/3)…", - }); - - resetSyncProgressThrottle(onProgress); - jest.advanceTimersByTime(SYNC_PROGRESS_THROTTLE_MS); + reportSyncProgress(onProgress, progress); + reportSyncProgress(undefined, progress); expect(onProgress).toHaveBeenCalledTimes(1); + expect(onProgress).toHaveBeenCalledWith(progress); }); }); diff --git a/src/seqta/utils/calendarSync/lessonSyncShared.ts b/src/seqta/utils/calendarSync/lessonSyncShared.ts index 864bdfcc..0dcf2c01 100644 --- a/src/seqta/utils/calendarSync/lessonSyncShared.ts +++ b/src/seqta/utils/calendarSync/lessonSyncShared.ts @@ -3,109 +3,31 @@ import { getStoredEventId, lessonDateFromSeqtaKey, normalizeEventMapEntry, -} from "@/seqta/utils/googleCalendar/eventMapEntry"; + type EventMapRecord, +} from "@/seqta/utils/calendarSync/eventMap"; import { isDateInRange, syncWindowRange, } from "@/seqta/utils/googleCalendar/syncDateRange"; import type { + GoogleCalendarDeleteResult, GoogleCalendarSyncOptions, GoogleCalendarSyncProgress, GoogleCalendarSyncResult, } from "@/seqta/utils/googleCalendar/types"; export const EVENT_MAP_PERSIST_EVERY = 10; -/** Max UI progress refresh rate during bulk delete/upsert (reduces Svelte re-renders). */ -export const SYNC_PROGRESS_THROTTLE_MS = 1000; - -export type EventMapRecord = Record; export type MappedLessonEvent = { seqtaKey: string; startDateTime: string; }; -type ProgressThrottleState = { - lastReportAt: number; - pending: GoogleCalendarSyncProgress | null; - timer: ReturnType | null; -}; - -const progressThrottleByCallback = new WeakMap< - NonNullable, - ProgressThrottleState ->(); - -function getProgressThrottleState( - onProgress: NonNullable, -): ProgressThrottleState { - let state = progressThrottleByCallback.get(onProgress); - if (!state) { - state = { lastReportAt: 0, pending: null, timer: null }; - progressThrottleByCallback.set(onProgress, state); - } - return state; -} - -function flushPendingSyncProgress( - onProgress: NonNullable, - state: ProgressThrottleState, -) { - if (state.timer) { - clearTimeout(state.timer); - state.timer = null; - } - if (!state.pending) return; - onProgress(state.pending); - state.pending = null; - state.lastReportAt = Date.now(); -} - -/** Clears any queued progress for a callback (e.g. when a sync run ends). */ -export function resetSyncProgressThrottle( - onProgress: GoogleCalendarSyncOptions["onProgress"], -) { - if (!onProgress) return; - const state = progressThrottleByCallback.get(onProgress); - if (!state) return; - if (state.timer) { - clearTimeout(state.timer); - state.timer = null; - } - state.pending = null; -} - export function reportSyncProgress( onProgress: GoogleCalendarSyncOptions["onProgress"], progress: GoogleCalendarSyncProgress, ) { - if (!onProgress) return; - - const state = getProgressThrottleState(onProgress); - - if (progress.phase === "preparing" || progress.phase === "done") { - flushPendingSyncProgress(onProgress, state); - onProgress(progress); - state.lastReportAt = Date.now(); - if (progress.phase === "done") { - resetSyncProgressThrottle(onProgress); - } - return; - } - - state.pending = progress; - const elapsed = Date.now() - state.lastReportAt; - if (elapsed >= SYNC_PROGRESS_THROTTLE_MS) { - flushPendingSyncProgress(onProgress, state); - return; - } - - if (state.timer) return; - - state.timer = setTimeout(() => { - state.timer = null; - flushPendingSyncProgress(onProgress, state); - }, SYNC_PROGRESS_THROTTLE_MS - elapsed); + onProgress?.(progress); } export function lessonDateForEvent(startDateTime: string, seqtaKey: string): string { @@ -126,18 +48,6 @@ export function originEventMapEntries( 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, @@ -145,6 +55,8 @@ export function entriesToPrune( weeksAhead: number, currentMapKeys: Set, ): Array<[string, string]> { + if (mode === "incremental") return []; + const window = syncWindowRange(weeksAhead); const prefix = `${origin}::`; const entries: Array<[string, string]> = []; @@ -153,9 +65,10 @@ export function entriesToPrune( if (!mapKey.startsWith(prefix)) continue; const entry = normalizeEventMapEntry(raw); if (!entry) continue; - if (shouldPruneEntry(mode, entry, mapKey, window, currentMapKeys)) { - entries.push([mapKey, entry.id]); - } + const stale = entry.date + ? !isDateInRange(entry.date, window) + : !currentMapKeys.has(mapKey); + if (stale) entries.push([mapKey, entry.id]); } return entries; @@ -178,6 +91,98 @@ export function emptyLessonsSyncResult(): GoogleCalendarSyncResult { }; } +export function formatLessonSyncResultMessage( + result: GoogleCalendarSyncResult, + calendarLabel: string, +): string { + const created = result.created ?? 0; + const updated = result.updated ?? 0; + const deleted = result.deleted ?? 0; + const parts: string[] = []; + if (created > 0) parts.push(`${created} new`); + if (updated > 0) parts.push(`${updated} updated`); + if (deleted > 0) parts.push(`${deleted} removed`); + if (parts.length === 0) return `${calendarLabel} is up to date.`; + return `${calendarLabel} updated (${parts.join(", ")}).`; +} + +export function buildDeleteSyncResult( + deleted: number, + failed: number, +): GoogleCalendarDeleteResult { + return { + success: failed === 0, + configured: true, + connected: true, + deleted, + failed, + error: + failed > 0 + ? `Removed ${deleted} event${deleted === 1 ? "" : "s"} with ${failed} error${failed === 1 ? "" : "s"}.` + : undefined, + }; +} + +export async function deleteTrackedLessonEvents( + entries: Array<[string, string]>, + eventMap: EventMapRecord, + getAccessToken: () => Promise, + deleteEvent: ( + accessToken: string, + eventId: string, + refreshAccessToken: () => Promise, + ) => Promise, + writeState: (patch: { eventMap: EventMapRecord }) => Promise, + options: { + persistProgress?: boolean; + onProgress?: GoogleCalendarSyncOptions["onProgress"]; + progressOffset?: number; + progressTotal?: number; + logLabel: string; + }, +): Promise<{ deleted: number; failed: number }> { + if (entries.length === 0) return { deleted: 0, failed: 0 }; + + const { + persistProgress = false, + onProgress, + progressOffset = 0, + progressTotal = 0, + logLabel, + } = options; + + let accessToken = await getAccessToken(); + let deleted = 0; + let failed = 0; + + for (const [mapKey, eventId] of entries) { + try { + await deleteEvent(accessToken, eventId, async () => { + accessToken = await getAccessToken(); + return accessToken; + }); + delete eventMap[mapKey]; + deleted += 1; + } catch (err) { + verboseLog(`[BetterSEQTA+] ${logLabel} event delete failed:`, err); + failed += 1; + } + + reportSyncProgress(onProgress, { + phase: "deleting", + current: progressOffset + deleted + failed, + total: progressTotal, + message: `Removing old events (${deleted + failed}/${entries.length})…`, + }); + + if (persistProgress && (deleted + failed) % EVENT_MAP_PERSIST_EVERY === 0) { + await writeState({ eventMap }); + } + } + + return { deleted, failed }; +} + export function buildLessonSyncResult( created: number, updated: number, @@ -202,24 +207,6 @@ export function buildLessonSyncResult( }; } -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; @@ -288,26 +275,20 @@ export async function upsertLessonEvents( 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, - }); } + + reportSyncProgress(onProgress, { + phase: "upserting", + current: progressCurrent, + total: totalSteps, + message: progressMessage, + }); } return { created, updated, failed, accessToken }; diff --git a/src/seqta/utils/calendarSync/providerStorage.ts b/src/seqta/utils/calendarSync/providerStorage.ts new file mode 100644 index 00000000..2ced9436 --- /dev/null +++ b/src/seqta/utils/calendarSync/providerStorage.ts @@ -0,0 +1,51 @@ +import type { EventMapRecord } from "@/seqta/utils/calendarSync/eventMap"; +import { createCalendarStateStorage } from "@/seqta/utils/calendarSync/eventMap"; + +export const BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY = "bsplus_google_calendar"; +export const BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY = "bsplus_outlook_calendar"; + +export interface GoogleCalendarStoredState { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + connectedAt?: number; + lastSyncAt?: number; + lastWeeklySyncAt?: number; + lastSyncOrigin?: string; + syncWeeksAhead?: number; + autoSyncWeekly?: boolean; + pendingWeeklySync?: boolean; + eventMap?: EventMapRecord; +} + +export interface OutlookCalendarStatus { + configured: boolean; + connected: boolean; + lastSyncAt?: number; + lastSyncOrigin?: string; +} + +export interface OutlookCalendarStoredState { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + connectedAt?: number; + lastSyncAt?: number; + lastSyncOrigin?: string; + eventMap?: EventMapRecord; +} + +const googleStorage = createCalendarStateStorage( + BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY, +); +const outlookStorage = createCalendarStateStorage( + BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY, +); + +export const readGoogleCalendarState = googleStorage.read; +export const writeGoogleCalendarState = googleStorage.write; +export const clearGoogleCalendarState = googleStorage.clear; + +export const readOutlookCalendarState = outlookStorage.read; +export const writeOutlookCalendarState = outlookStorage.write; +export const clearOutlookCalendarState = outlookStorage.clear; diff --git a/src/seqta/utils/calendarSync/remoteEvents.ts b/src/seqta/utils/calendarSync/remoteEvents.ts new file mode 100644 index 00000000..f16ce703 --- /dev/null +++ b/src/seqta/utils/calendarSync/remoteEvents.ts @@ -0,0 +1,144 @@ +import { GOOGLE_CALENDAR_API } from "@/config/googleCalendar"; +import { OUTLOOK_GRAPH_API } from "@/config/outlookCalendar"; + +async function authorizedFetch( + accessToken: string, + url: string, + init: RequestInit, + refreshAccessToken?: () => Promise, +): Promise { + const res = await fetch(url, { + ...init, + headers: { Authorization: `Bearer ${accessToken}`, ...init.headers }, + }); + if (res.status === 401 && refreshAccessToken) { + return authorizedFetch(await refreshAccessToken(), url, init); + } + return res; +} + +async function upsertRemoteEvent( + accessToken: string, + existingEventId: string | undefined, + body: Record, + paths: { update: (id: string) => string; create: string }, + label: string, + refreshAccessToken?: () => Promise, +): Promise { + const headers = { "Content-Type": "application/json" }; + + if (existingEventId) { + const res = await authorizedFetch( + accessToken, + paths.update(existingEventId), + { method: "PATCH", headers, body: JSON.stringify(body) }, + refreshAccessToken, + ); + if (res.ok) return existingEventId; + if (res.status !== 404) { + const err = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; + throw new Error(err?.error?.message ?? `${label} update failed (${res.status})`); + } + } + + const res = await authorizedFetch( + accessToken, + paths.create, + { method: "POST", headers, body: JSON.stringify(body) }, + refreshAccessToken, + ); + const json = (await res.json().catch(() => ({}))) as { id?: string; error?: { message?: string } }; + if (!res.ok || !json.id) { + throw new Error(json?.error?.message ?? `${label} create failed (${res.status})`); + } + return json.id; +} + +async function deleteRemoteEvent( + accessToken: string, + eventUrl: string, + label: string, + refreshAccessToken?: () => Promise, +): Promise { + const res = await authorizedFetch( + accessToken, + eventUrl, + { method: "DELETE" }, + refreshAccessToken, + ); + if (res.ok || res.status === 404 || res.status === 410) return; + const err = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; + throw new Error(err?.error?.message ?? `${label} delete failed (${res.status})`); +} + +const GOOGLE_CALENDAR_ID = "primary"; + +export function upsertGoogleCalendarEvent( + accessToken: string, + calendarId: string, + existingEventId: string | undefined, + body: Record, + refreshAccessToken?: () => Promise, +): Promise { + const encodedCalendar = encodeURIComponent(calendarId); + return upsertRemoteEvent( + accessToken, + existingEventId, + body, + { + update: (id) => + `${GOOGLE_CALENDAR_API}/calendars/${encodedCalendar}/events/${encodeURIComponent(id)}`, + create: `${GOOGLE_CALENDAR_API}/calendars/${encodedCalendar}/events`, + }, + "Google Calendar", + refreshAccessToken, + ); +} + +export function deleteGoogleCalendarEvent( + accessToken: string, + calendarId: string, + eventId: string, + refreshAccessToken?: () => Promise, +): Promise { + return deleteRemoteEvent( + accessToken, + `${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, + "Google Calendar", + refreshAccessToken, + ); +} + +export function upsertOutlookCalendarEvent( + accessToken: string, + existingEventId: string | undefined, + body: Record, + refreshAccessToken?: () => Promise, +): Promise { + return upsertRemoteEvent( + accessToken, + existingEventId, + body, + { + update: (id) => `${OUTLOOK_GRAPH_API}/me/events/${encodeURIComponent(id)}`, + create: `${OUTLOOK_GRAPH_API}/me/events`, + }, + "Outlook Calendar", + refreshAccessToken, + ); +} + +export function deleteOutlookCalendarEvent( + accessToken: string, + eventId: string, + refreshAccessToken?: () => Promise, +): Promise { + return deleteRemoteEvent( + accessToken, + `${OUTLOOK_GRAPH_API}/me/events/${encodeURIComponent(eventId)}`, + "Outlook Calendar", + refreshAccessToken, + ); +} + +export { GOOGLE_CALENDAR_ID }; diff --git a/src/seqta/utils/calendarSync/settings.ts b/src/seqta/utils/calendarSync/settings.ts index ab6e1fd2..9f188d7c 100644 --- a/src/seqta/utils/calendarSync/settings.ts +++ b/src/seqta/utils/calendarSync/settings.ts @@ -1,20 +1,53 @@ +import browser from "webextension-polyfill"; import { - GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT, + GOOGLE_CALENDAR_SYNC_WEEKS, GOOGLE_CALENDAR_SYNC_WEEKS_MAX, GOOGLE_CALENDAR_SYNC_WEEKS_MIN, } from "@/config/googleCalendar"; -import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage"; import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; -import { - readSharedCalendarSyncSettings, - WEEKLY_SYNC_INTERVAL_MS, - writeSharedCalendarSyncSettings, -} from "./sharedSettings"; +import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage"; -export { CALENDAR_WEEKLY_ALARM, WEEKLY_SYNC_INTERVAL_MS } from "./sharedSettings"; +export const BSPLUS_CALENDAR_SYNC_SETTINGS_KEY = "bsplus_calendar_sync_settings"; +export const CALENDAR_WEEKLY_ALARM = "bsplus_calendar_weekly"; +export const WEEKLY_SYNC_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; + +export interface SharedCalendarSyncSettings { + syncWeeksAhead?: number; + autoSyncWeekly?: boolean; + lastWeeklySyncAt?: number; + pendingWeeklySync?: boolean; +} + +export async function readSharedCalendarSyncSettings(): Promise { + const got = await browser.storage.local.get(BSPLUS_CALENDAR_SYNC_SETTINGS_KEY); + const raw = got[BSPLUS_CALENDAR_SYNC_SETTINGS_KEY]; + const shared = + raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as SharedCalendarSyncSettings) + : {}; + + if (Object.keys(shared).length > 0) return shared; + + const legacy = await readGoogleCalendarState(); + return { + syncWeeksAhead: legacy.syncWeeksAhead, + autoSyncWeekly: legacy.autoSyncWeekly, + lastWeeklySyncAt: legacy.lastWeeklySyncAt, + pendingWeeklySync: legacy.pendingWeeklySync, + }; +} + +export async function writeSharedCalendarSyncSettings( + patch: Partial, +): Promise { + const current = await readSharedCalendarSyncSettings(); + const next = { ...current, ...patch }; + await browser.storage.local.set({ [BSPLUS_CALENDAR_SYNC_SETTINGS_KEY]: next }); + return next; +} export function clampSyncWeeks(weeks: number): number { - if (!Number.isFinite(weeks)) return GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT; + if (!Number.isFinite(weeks)) return GOOGLE_CALENDAR_SYNC_WEEKS; return Math.min( GOOGLE_CALENDAR_SYNC_WEEKS_MAX, Math.max(GOOGLE_CALENDAR_SYNC_WEEKS_MIN, Math.round(weeks)), @@ -23,7 +56,7 @@ export function clampSyncWeeks(weeks: number): number { export async function getSyncWeeksAhead(): Promise { const settings = await readSharedCalendarSyncSettings(); - return clampSyncWeeks(settings.syncWeeksAhead ?? GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT); + return clampSyncWeeks(settings.syncWeeksAhead ?? GOOGLE_CALENDAR_SYNC_WEEKS); } export async function getAutoSyncWeekly(): Promise { @@ -31,7 +64,7 @@ export async function getAutoSyncWeekly(): Promise { return settings.autoSyncWeekly !== false; } -async function isAnyCalendarConnected(): Promise { +export async function isAnyCalendarConnected(): Promise { const [google, outlook] = await Promise.all([ readGoogleCalendarState(), readOutlookCalendarState(), diff --git a/src/seqta/utils/calendarSync/sharedSettings.ts b/src/seqta/utils/calendarSync/sharedSettings.ts deleted file mode 100644 index 543728b3..00000000 --- a/src/seqta/utils/calendarSync/sharedSettings.ts +++ /dev/null @@ -1,48 +0,0 @@ -import browser from "webextension-polyfill"; -import { - GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT, -} from "@/config/googleCalendar"; -import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; - -export const BSPLUS_CALENDAR_SYNC_SETTINGS_KEY = "bsplus_calendar_sync_settings"; -export const CALENDAR_WEEKLY_ALARM = "bsplus_calendar_weekly"; -export const WEEKLY_SYNC_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; - -export interface SharedCalendarSyncSettings { - syncWeeksAhead?: number; - autoSyncWeekly?: boolean; - lastWeeklySyncAt?: number; - pendingWeeklySync?: boolean; -} - -export async function readSharedCalendarSyncSettings(): Promise { - const got = await browser.storage.local.get(BSPLUS_CALENDAR_SYNC_SETTINGS_KEY); - const raw = got[BSPLUS_CALENDAR_SYNC_SETTINGS_KEY]; - const shared = - raw && typeof raw === "object" && !Array.isArray(raw) - ? (raw as SharedCalendarSyncSettings) - : {}; - - if (Object.keys(shared).length > 0) return shared; - - const legacy = await readGoogleCalendarState(); - return { - syncWeeksAhead: legacy.syncWeeksAhead, - autoSyncWeekly: legacy.autoSyncWeekly, - lastWeeklySyncAt: legacy.lastWeeklySyncAt, - pendingWeeklySync: legacy.pendingWeeklySync, - }; -} - -export async function writeSharedCalendarSyncSettings( - patch: Partial, -): Promise { - const current = await readSharedCalendarSyncSettings(); - const next = { ...current, ...patch }; - await browser.storage.local.set({ [BSPLUS_CALENDAR_SYNC_SETTINGS_KEY]: next }); - return next; -} - -export function defaultSyncWeeksAhead(): number { - return GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT; -} diff --git a/src/seqta/utils/calendarSync/syncEngine.ts b/src/seqta/utils/calendarSync/syncEngine.ts new file mode 100644 index 00000000..fc683e34 --- /dev/null +++ b/src/seqta/utils/calendarSync/syncEngine.ts @@ -0,0 +1,285 @@ +import { isGoogleCalendarConfigured } from "@/config/googleCalendar"; +import { isOutlookCalendarConfigured } from "@/config/outlookCalendar"; +import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; +import { eventMapKey } from "@/seqta/utils/calendarSync/eventMap"; +import type { EventMapRecord } from "@/seqta/utils/calendarSync/eventMap"; +import { + buildDeleteSyncResult, + buildLessonSyncResult, + deleteTrackedLessonEvents, + emptyLessonsSyncResult, + entriesToPrune, + notConfiguredSyncResult, + notConnectedSyncResult, + originEventMapEntries, + 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, + GOOGLE_CALENDAR_ID, + upsertGoogleCalendarEvent, + upsertOutlookCalendarEvent, +} from "@/seqta/utils/calendarSync/remoteEvents"; +import { + readOutlookCalendarState, + writeOutlookCalendarState, +} from "@/seqta/utils/outlookCalendar/storage"; + +type CalendarStoredState = { + refreshToken?: string; + accessToken?: string; + eventMap?: EventMapRecord; +}; + +export type CalendarLessonSyncProvider = { + label: string; + isConfigured: () => boolean; + notConfiguredError: string; + notConnectedError: string; + readState: () => Promise; + writeState: (patch: { + eventMap?: EventMapRecord; + lastSyncAt?: number; + lastSyncOrigin?: string; + }) => Promise; + deleteEvent: ( + accessToken: string, + eventId: string, + refreshAccessToken: () => Promise, + ) => Promise; + upsertEvent: ( + accessToken: string, + existingId: string | undefined, + body: Record, + refreshAccessToken: () => Promise, + ) => Promise; + toApiBody: (event: GoogleCalendarEventInput) => Record; +}; + +export const googleLessonSyncProvider: CalendarLessonSyncProvider = { + label: "Google Calendar", + isConfigured: isGoogleCalendarConfigured, + notConfiguredError: "Google Calendar is not configured in this extension build.", + notConnectedError: "Connect Google Calendar first.", + readState: readGoogleCalendarState, + writeState: writeGoogleCalendarState, + deleteEvent: (accessToken, eventId, refreshAccessToken) => + deleteGoogleCalendarEvent(accessToken, GOOGLE_CALENDAR_ID, eventId, refreshAccessToken), + upsertEvent: (accessToken, existingId, body, refreshAccessToken) => + upsertGoogleCalendarEvent( + accessToken, + GOOGLE_CALENDAR_ID, + existingId, + body, + refreshAccessToken, + ), + toApiBody: googleApiEventBody, +}; + +export const outlookLessonSyncProvider: CalendarLessonSyncProvider = { + label: "Outlook Calendar", + isConfigured: isOutlookCalendarConfigured, + notConfiguredError: "Outlook Calendar is not configured in this extension build.", + notConnectedError: "Connect Outlook Calendar first.", + readState: readOutlookCalendarState, + writeState: writeOutlookCalendarState, + deleteEvent: (accessToken, eventId, refreshAccessToken) => + deleteOutlookCalendarEvent(accessToken, eventId, refreshAccessToken), + upsertEvent: (accessToken, existingId, body, refreshAccessToken) => + upsertOutlookCalendarEvent(accessToken, existingId, body, refreshAccessToken), + toApiBody: outlookGraphEventBody, +}; + +/** Runs in the content script tab so long syncs are not killed by the MV3 service worker. */ +export async function syncLessonsToCalendar( + provider: CalendarLessonSyncProvider, + request: GoogleCalendarSyncRequest, + getAccessToken: () => Promise, + options: GoogleCalendarSyncOptions = {}, +): Promise { + if (!provider.isConfigured()) { + return notConfiguredSyncResult(provider.notConfiguredError); + } + + const state = await provider.readState(); + if (!state.refreshToken && !state.accessToken) { + return notConnectedSyncResult(provider.notConnectedError); + } + + const mode = request.mode ?? "full"; + const weeksAhead = request.weeksAhead ?? (await getSyncWeeksAhead()); + 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, + total: Math.max(events.length, 1), + message: mode === "incremental" ? "Preparing weekly sync…" : "Preparing sync…", + }); + + 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 deleteTrackedLessonEvents( + staleEntries, + eventMap, + getAccessToken, + provider.deleteEvent, + provider.writeState, + { + onProgress: options.onProgress, + progressTotal: totalSteps, + logLabel: provider.label, + }, + ); + + 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) => + provider.upsertEvent( + accessToken, + existingId, + provider.toApiBody(event), + refreshAccessToken, + ), + writeState: provider.writeState, + onProgress: options.onProgress, + logLabel: provider.label, + }); + + if (staleResult.deleted > 0 || staleEntries.length > 0 || events.length > 0) { + await provider.writeState({ + eventMap, + lastSyncAt, + lastSyncOrigin: request.origin, + }); + } + + reportSyncProgress(options.onProgress, { + phase: "done", + current: totalSteps, + total: totalSteps, + message: "Sync complete", + }); + + return buildLessonSyncResult( + upsertResult.created, + upsertResult.updated, + staleResult.deleted, + upsertResult.failed, + lastSyncAt, + ); +} + +/** Delete all tracked BetterSEQTA+ events for this SEQTA origin from the provider calendar. */ +export async function deleteSyncedEventsFromCalendar( + provider: CalendarLessonSyncProvider, + origin: string, + getAccessToken: () => Promise, + options: GoogleCalendarSyncOptions = {}, +): Promise { + if (!provider.isConfigured()) { + return { + success: false, + configured: false, + error: provider.notConfiguredError, + }; + } + + const state = await provider.readState(); + if (!state.refreshToken && !state.accessToken) { + 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, + message: "Preparing removal…", + }); + + const eventMap = { ...(state.eventMap ?? {}) }; + const { deleted, failed } = await deleteTrackedLessonEvents( + entries, + eventMap, + getAccessToken, + provider.deleteEvent, + provider.writeState, + { + persistProgress: true, + onProgress: options.onProgress, + progressTotal: entries.length, + logLabel: provider.label, + }, + ); + + await provider.writeState({ eventMap }); + + reportSyncProgress(options.onProgress, { + phase: "done", + current: entries.length, + total: entries.length, + message: "Removal complete", + }); + + return buildDeleteSyncResult(deleted, failed); +} + +export const syncLessonsToGoogleCalendar = ( + request: GoogleCalendarSyncRequest, + getAccessToken: () => Promise, + options?: GoogleCalendarSyncOptions, +) => syncLessonsToCalendar(googleLessonSyncProvider, request, getAccessToken, options); + +export const deleteSyncedEventsFromGoogleCalendar = ( + origin: string, + getAccessToken: () => Promise, + options?: GoogleCalendarSyncOptions, +) => deleteSyncedEventsFromCalendar(googleLessonSyncProvider, origin, getAccessToken, options); + +export const syncLessonsToOutlookCalendar = ( + request: GoogleCalendarSyncRequest, + getAccessToken: () => Promise, + options?: GoogleCalendarSyncOptions, +) => syncLessonsToCalendar(outlookLessonSyncProvider, request, getAccessToken, options); + +export const deleteSyncedEventsFromOutlookCalendar = ( + origin: string, + getAccessToken: () => Promise, + options?: GoogleCalendarSyncOptions, +) => deleteSyncedEventsFromCalendar(outlookLessonSyncProvider, origin, getAccessToken, options); diff --git a/src/seqta/utils/calendarSync/syncRunner.ts b/src/seqta/utils/calendarSync/syncRunner.ts new file mode 100644 index 00000000..e59f54cc --- /dev/null +++ b/src/seqta/utils/calendarSync/syncRunner.ts @@ -0,0 +1,86 @@ +import browser from "webextension-polyfill"; +import { fetchTimetableForSync, fetchTimetableLessons } from "@/seqta/utils/googleCalendar/fetchTimetable"; +import { trailingWeekRange } from "@/seqta/utils/googleCalendar/syncDateRange"; +import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; +import { reportSyncProgress } from "@/seqta/utils/calendarSync/lessonSyncShared"; +import { + googleLessonSyncProvider, + outlookLessonSyncProvider, + syncLessonsToCalendar, + type CalendarLessonSyncProvider, +} from "@/seqta/utils/calendarSync/syncEngine"; +import type { + GoogleCalendarSyncProgress, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; + +export interface RunCalendarSyncParams { + mode?: "full" | "incremental"; + onProgress?: (progress: GoogleCalendarSyncProgress) => void; +} + +type CalendarSyncRunnerConfig = { + accessTokenMessageType: string; + accessTokenError: string; + lessonSyncProvider: CalendarLessonSyncProvider; +}; + +const GOOGLE_SYNC_RUNNER: CalendarSyncRunnerConfig = { + accessTokenMessageType: "googleCalendarGetAccessToken", + accessTokenError: "Could not get Google Calendar access token.", + lessonSyncProvider: googleLessonSyncProvider, +}; + +const OUTLOOK_SYNC_RUNNER: CalendarSyncRunnerConfig = { + accessTokenMessageType: "outlookCalendarGetAccessToken", + accessTokenError: "Could not get Outlook Calendar access token.", + lessonSyncProvider: outlookLessonSyncProvider, +}; + +async function getAccessTokenFromBackground( + messageType: string, + errorMessage: string, +): Promise { + const res = (await browser.runtime.sendMessage({ type: messageType })) as { + success?: boolean; + accessToken?: string; + error?: string; + }; + if (!res?.success || !res.accessToken) { + throw new Error(res?.error ?? errorMessage); + } + return res.accessToken; +} + +export async function runCalendarSync( + config: CalendarSyncRunnerConfig, + params: RunCalendarSyncParams = {}, +): Promise { + const mode = params.mode ?? "full"; + const weeksAhead = await getSyncWeeksAhead(); + + reportSyncProgress(params.onProgress, { + phase: "preparing", + current: 0, + total: 1, + message: mode === "incremental" ? "Fetching new week…" : "Fetching timetable…", + }); + + const lessons = + mode === "incremental" + ? await fetchTimetableLessons(trailingWeekRange(weeksAhead)) + : await fetchTimetableForSync(weeksAhead); + + return syncLessonsToCalendar( + config.lessonSyncProvider, + { origin: location.origin, lessons, mode, weeksAhead }, + () => getAccessTokenFromBackground(config.accessTokenMessageType, config.accessTokenError), + { onProgress: params.onProgress }, + ); +} + +export const runGoogleCalendarSync = (params?: RunCalendarSyncParams) => + runCalendarSync(GOOGLE_SYNC_RUNNER, params); + +export const runOutlookCalendarSync = (params?: RunCalendarSyncParams) => + runCalendarSync(OUTLOOK_SYNC_RUNNER, params); diff --git a/src/seqta/utils/calendarSync/weeklySyncMessages.ts b/src/seqta/utils/calendarSync/weeklySyncMessages.ts deleted file mode 100644 index 249be3a7..00000000 --- a/src/seqta/utils/calendarSync/weeklySyncMessages.ts +++ /dev/null @@ -1,51 +0,0 @@ -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/accountsToken.ts b/src/seqta/utils/googleCalendar/accountsToken.ts deleted file mode 100644 index 8aad0c06..00000000 --- a/src/seqta/utils/googleCalendar/accountsToken.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - GOOGLE_CALENDAR_ACCOUNTS_NOT_READY_HINT, - GOOGLE_CALENDAR_REFRESH_URL, - GOOGLE_CALENDAR_TOKEN_URL, -} from "@/config/googleCalendar"; - -type GoogleTokenPayload = { - access_token: string; - refresh_token?: string; - expires_in?: number; -}; - -async function parseAccountsJson(res: Response): Promise> { - const text = await res.text(); - try { - return text ? (JSON.parse(text) as Record) : {}; - } catch { - return {}; - } -} - -function extractTokens(json: Record): GoogleTokenPayload { - const access_token = json.access_token; - if (typeof access_token !== "string" || !access_token) { - throw new Error("Token response missing access_token"); - } - return { - access_token, - refresh_token: typeof json.refresh_token === "string" ? json.refresh_token : undefined, - expires_in: typeof json.expires_in === "number" ? json.expires_in : undefined, - }; -} - -function formatAccountsTokenError(res: Response, json: Record): string { - if (res.status === 404 || res.status === 501) { - return GOOGLE_CALENDAR_ACCOUNTS_NOT_READY_HINT; - } - const err = typeof json.error === "string" ? json.error : ""; - return err || `Accounts token API failed (${res.status})`; -} - -export async function exchangeGoogleCodeViaAccounts( - code: string, - redirectUri: string, - codeVerifier: string, -): Promise { - const res = await fetch(GOOGLE_CALENDAR_TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }), - }); - const json = await parseAccountsJson(res); - if (!res.ok) { - throw new Error(formatAccountsTokenError(res, json)); - } - return extractTokens(json); -} - -export async function refreshGoogleTokenViaAccounts( - refreshToken: string, -): Promise { - const res = await fetch(GOOGLE_CALENDAR_REFRESH_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh_token: refreshToken }), - }); - const json = await parseAccountsJson(res); - if (!res.ok) { - throw new Error(formatAccountsTokenError(res, json)); - } - return extractTokens(json); -} diff --git a/src/seqta/utils/googleCalendar/calendarSyncListener.ts b/src/seqta/utils/googleCalendar/calendarSyncListener.ts index e4d750eb..b2cd406b 100644 --- a/src/seqta/utils/googleCalendar/calendarSyncListener.ts +++ b/src/seqta/utils/googleCalendar/calendarSyncListener.ts @@ -3,33 +3,38 @@ import { markWeeklySyncComplete, shouldRunWeeklySync, } from "@/seqta/utils/calendarSync/settings"; -import { - formatWeeklySyncMessages, - weeklySyncErrorMessage, -} from "@/seqta/utils/calendarSync/weeklySyncMessages"; -import { runGoogleCalendarSync } from "@/seqta/utils/googleCalendar/syncRunner"; -import { runOutlookCalendarSync } from "@/seqta/utils/outlookCalendar/syncRunner"; +import { formatLessonSyncResultMessage } from "@/seqta/utils/calendarSync/lessonSyncShared"; +import { runGoogleCalendarSync, runOutlookCalendarSync } from "@/seqta/utils/calendarSync/syncRunner"; import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage"; import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; let listenerRegistered = false; -async function runWeeklySyncForConnectedProviders(): Promise { - const [google, outlook] = await Promise.all([ - readGoogleCalendarState(), - readOutlookCalendarState(), - ]); - const results: GoogleCalendarSyncResult[] = []; +const WEEKLY_PROVIDERS = [ + { label: "Google Calendar", read: readGoogleCalendarState, run: runGoogleCalendarSync }, + { label: "Outlook Calendar", read: readOutlookCalendarState, run: runOutlookCalendarSync }, +] as const; - if (google.refreshToken || google.accessToken) { - results.push(await runGoogleCalendarSync({ mode: "incremental", silent: true })); - } - if (outlook.refreshToken || outlook.accessToken) { - results.push(await runOutlookCalendarSync({ mode: "incremental", silent: true })); +function isConnected(state: { refreshToken?: string; accessToken?: string }): boolean { + return Boolean(state.refreshToken || state.accessToken); +} + +function hadChanges(result: GoogleCalendarSyncResult): boolean { + return (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0; +} + +async function runWeeklySyncForConnectedProviders(): Promise< + Array<{ label: string; result: GoogleCalendarSyncResult }> +> { + const results: Array<{ label: string; result: GoogleCalendarSyncResult }> = []; + + for (const provider of WEEKLY_PROVIDERS) { + if (!isConnected(await provider.read())) continue; + results.push({ label: provider.label, result: await provider.run({ mode: "incremental" }) }); } - if (results.some((r) => r.success)) { + if (results.some(({ result }) => result.success)) { await markWeeklySyncComplete(); } @@ -61,24 +66,20 @@ export async function maybeRunDueWeeklySync( ): Promise { if (!(await shouldRunWeeklySync())) return; - const [google, outlook] = await Promise.all([ - readGoogleCalendarState(), - readOutlookCalendarState(), - ]); const results = await runWeeklySyncForConnectedProviders(); if (!onComplete) return; - const errorMessage = weeklySyncErrorMessage(results); - if (errorMessage) { - onComplete(errorMessage, true); + const failed = results.find(({ result }) => !result.success); + if (failed) { + onComplete(failed.result.error ?? "Weekly calendar sync failed.", true); return; } - const messages = formatWeeklySyncMessages(google, outlook, results); + const messages = results + .filter(({ result }) => hadChanges(result)) + .map(({ label, result }) => formatLessonSyncResultMessage(result, label)); + if (messages.length > 0) { onComplete(messages.join(" ")); } } - -/** @deprecated use registerCalendarContentHandlers */ -export const registerGoogleCalendarContentHandlers = registerCalendarContentHandlers; diff --git a/src/seqta/utils/googleCalendar/eventMapEntry.ts b/src/seqta/utils/googleCalendar/eventMapEntry.ts deleted file mode 100644 index 3428aa4c..00000000 --- a/src/seqta/utils/googleCalendar/eventMapEntry.ts +++ /dev/null @@ -1,29 +0,0 @@ -export interface GoogleCalendarEventMapEntry { - id: string; - date: string; -} - -export function normalizeEventMapEntry( - value: string | GoogleCalendarEventMapEntry | undefined, -): GoogleCalendarEventMapEntry | undefined { - if (value == null) return undefined; - if (typeof value === "string") return { id: value, date: "" }; - if (typeof value.id === "string" && value.id.length > 0) { - return { id: value.id, date: value.date ?? "" }; - } - return undefined; -} - -export function getStoredEventId( - value: string | GoogleCalendarEventMapEntry | undefined, -): string | undefined { - return normalizeEventMapEntry(value)?.id; -} - -export function lessonDateFromSeqtaKey(seqtaKey: string): string | undefined { - const parts = seqtaKey.split(":"); - for (const part of parts) { - if (/^\d{4}-\d{2}-\d{2}$/.test(part)) return part; - } - return undefined; -} diff --git a/src/seqta/utils/googleCalendar/eventMapper.ts b/src/seqta/utils/googleCalendar/eventMapper.ts index 5d708fc4..03b4d4f2 100644 --- a/src/seqta/utils/googleCalendar/eventMapper.ts +++ b/src/seqta/utils/googleCalendar/eventMapper.ts @@ -1,4 +1,5 @@ import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar"; +import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar"; import type { GoogleCalendarEventInput, SeqtaTimetableLesson } from "./types"; const SKIP_TYPES = new Set(["note", "holiday", "assembly-note"]); @@ -93,3 +94,20 @@ export function googleApiEventBody(event: GoogleCalendarEventInput): Record { + const body: Record = { + subject: event.summary, + body: { + contentType: "text", + content: event.description ?? "Synced by BetterSEQTA+", + }, + start: { dateTime: event.startDateTime, timeZone: event.timeZone }, + end: { dateTime: event.endDateTime, timeZone: event.timeZone }, + categories: [BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY], + }; + if (event.location) { + body.location = { displayName: event.location }; + } + return body; +} diff --git a/src/seqta/utils/googleCalendar/fetchTimetable.ts b/src/seqta/utils/googleCalendar/fetchTimetable.ts index ffbc0959..f5c7444b 100644 --- a/src/seqta/utils/googleCalendar/fetchTimetable.ts +++ b/src/seqta/utils/googleCalendar/fetchTimetable.ts @@ -76,5 +76,3 @@ export async function fetchTimetableLessons( export async function fetchTimetableForSync(weeksAhead?: number): Promise { return fetchTimetableLessons(syncWindowRange(weeksAhead)); } - -export { syncWindowRange, trailingWeekRange, droppedWeekRange } from "./syncDateRange"; diff --git a/src/seqta/utils/googleCalendar/storage.ts b/src/seqta/utils/googleCalendar/storage.ts index 68b3bf60..e791ab64 100644 --- a/src/seqta/utils/googleCalendar/storage.ts +++ b/src/seqta/utils/googleCalendar/storage.ts @@ -1,44 +1,9 @@ -import browser from "webextension-polyfill"; -import type { GoogleCalendarEventMapEntry } from "./eventMapEntry"; +export { + BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY, + clearGoogleCalendarState, + readGoogleCalendarState, + writeGoogleCalendarState, + type GoogleCalendarStoredState, +} from "@/seqta/utils/calendarSync/providerStorage"; -/** Never uploaded to BetterSEQTA Cloud (OAuth tokens + per-device event map). */ -export const BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY = "bsplus_google_calendar"; - -export interface GoogleCalendarStoredState { - accessToken?: string; - refreshToken?: string; - expiresAt?: number; - connectedAt?: number; - lastSyncAt?: number; - lastWeeklySyncAt?: number; - lastSyncOrigin?: string; - syncWeeksAhead?: number; - autoSyncWeekly?: boolean; - pendingWeeklySync?: boolean; - /** `${origin}::${seqtaKey}` → Google event id (+ lesson date when known) */ - eventMap?: Record; -} - -export async function readGoogleCalendarState(): Promise { - const got = await browser.storage.local.get(BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY); - const raw = got[BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY]; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; - return raw as GoogleCalendarStoredState; -} - -export async function writeGoogleCalendarState( - patch: Partial, -): Promise { - const current = await readGoogleCalendarState(); - const next: GoogleCalendarStoredState = { ...current, ...patch }; - await browser.storage.local.set({ [BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY]: next }); - return next; -} - -export async function clearGoogleCalendarState(): Promise { - await browser.storage.local.remove(BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY); -} - -export function eventMapKey(origin: string, seqtaKey: string): string { - return `${origin}::${seqtaKey}`; -} +export type { EventMapRecord as GoogleCalendarEventMapRecord } from "@/seqta/utils/calendarSync/eventMap"; diff --git a/src/seqta/utils/googleCalendar/syncDateRange.ts b/src/seqta/utils/googleCalendar/syncDateRange.ts index cd9b87ab..35057ece 100644 --- a/src/seqta/utils/googleCalendar/syncDateRange.ts +++ b/src/seqta/utils/googleCalendar/syncDateRange.ts @@ -1,6 +1,4 @@ -import { - GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT, -} from "@/config/googleCalendar"; +import { GOOGLE_CALENDAR_SYNC_WEEKS } from "@/config/googleCalendar"; import { toISODate, weekRangeContaining } from "@/seqta/utils/Loaders/engageParentTimetable"; export interface SyncDateRange { @@ -12,8 +10,15 @@ function parseLocalDate(iso: string): Date { return new Date(`${iso}T12:00:00`); } +function weekEndingOn(until: string): SyncDateRange { + const end = parseLocalDate(until); + const start = new Date(end); + start.setDate(start.getDate() - 6); + return { from: toISODate(start), until }; +} + /** Full rolling sync window from the start of the current week. */ -export function syncWindowRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT): SyncDateRange { +export function syncWindowRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS): SyncDateRange { const { from } = weekRangeContaining(new Date()); const end = parseLocalDate(from); end.setDate(end.getDate() + weeksAhead * 7 - 1); @@ -21,23 +26,15 @@ export function syncWindowRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT) } /** The trailing week at the end of the sync window (added each weekly roll). */ -export function trailingWeekRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT): SyncDateRange { - const { from: windowStart } = syncWindowRange(weeksAhead); - const start = parseLocalDate(windowStart); - start.setDate(start.getDate() + (weeksAhead - 1) * 7); - const end = new Date(start); - end.setDate(end.getDate() + 6); - return { from: toISODate(start), until: toISODate(end) }; +export function trailingWeekRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS): SyncDateRange { + return weekEndingOn(syncWindowRange(weeksAhead).until); } /** The week that rolled off when the window advances (removed each weekly roll). */ -export function droppedWeekRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT): SyncDateRange { - const { from: windowStart } = syncWindowRange(weeksAhead); - const end = parseLocalDate(windowStart); +export function droppedWeekRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS): SyncDateRange { + const end = parseLocalDate(syncWindowRange(weeksAhead).from); end.setDate(end.getDate() - 1); - const start = new Date(end); - start.setDate(start.getDate() - 6); - return { from: toISODate(start), until: toISODate(end) }; + return weekEndingOn(toISODate(end)); } export function isDateInRange(date: string, range: SyncDateRange): boolean { diff --git a/src/seqta/utils/googleCalendar/syncEngine.test.ts b/src/seqta/utils/googleCalendar/syncEngine.test.ts index 595ef9d7..6f26e1d2 100644 --- a/src/seqta/utils/googleCalendar/syncEngine.test.ts +++ b/src/seqta/utils/googleCalendar/syncEngine.test.ts @@ -9,17 +9,22 @@ jest.mock("@/utils/verboseLog", () => ({ verboseLog: jest.fn(), })); -jest.mock("@/seqta/utils/googleCalendar/storage", () => ({ - eventMapKey: (origin: string, seqtaKey: string) => `${origin}::${seqtaKey}`, - readGoogleCalendarState: jest.fn(), - writeGoogleCalendarState: jest.fn(async (patch: unknown) => patch), -})); +jest.mock("@/seqta/utils/googleCalendar/storage", () => { + const actual = jest.requireActual( + "@/seqta/utils/googleCalendar/storage", + ); + return { + ...actual, + readGoogleCalendarState: jest.fn(), + writeGoogleCalendarState: jest.fn(async (patch: unknown) => patch), + }; +}); jest.mock("@/seqta/utils/calendarSync/settings", () => ({ getSyncWeeksAhead: jest.fn(async () => 12), })); -jest.mock("@/seqta/utils/googleCalendar/upsertEvent", () => ({ +jest.mock("@/seqta/utils/calendarSync/remoteEvents", () => ({ upsertGoogleCalendarEvent: jest.fn(), deleteGoogleCalendarEvent: jest.fn(), })); @@ -28,8 +33,8 @@ import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; import { deleteGoogleCalendarEvent, upsertGoogleCalendarEvent, -} from "@/seqta/utils/googleCalendar/upsertEvent"; -import { deleteSyncedEventsFromGoogleCalendar, syncLessonsToGoogleCalendar } from "./syncEngine"; +} from "@/seqta/utils/calendarSync/remoteEvents"; +import { deleteSyncedEventsFromGoogleCalendar, syncLessonsToGoogleCalendar } from "@/seqta/utils/calendarSync/syncEngine"; const ORIGIN = "https://school.seqta.com.au"; const getAccessToken = async () => "test-token"; diff --git a/src/seqta/utils/googleCalendar/syncEngine.ts b/src/seqta/utils/googleCalendar/syncEngine.ts deleted file mode 100644 index 2517043a..00000000 --- a/src/seqta/utils/googleCalendar/syncEngine.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { verboseLog } from "@/utils/verboseLog"; -import { isGoogleCalendarConfigured } from "@/config/googleCalendar"; -import { googleApiEventBody, mapLessonsToGoogleEvents } from "@/seqta/utils/googleCalendar/eventMapper"; -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, - writeGoogleCalendarState, -} from "@/seqta/utils/googleCalendar/storage"; -import type { - GoogleCalendarDeleteResult, - GoogleCalendarSyncOptions, - GoogleCalendarSyncRequest, - GoogleCalendarSyncResult, -} from "@/seqta/utils/googleCalendar/types"; -import { - deleteGoogleCalendarEvent, - upsertGoogleCalendarEvent, -} from "@/seqta/utils/googleCalendar/upsertEvent"; - -const CALENDAR_ID = "primary"; - -type DeleteTrackedEventsResult = { - deleted: number; - failed: number; -}; - -async function deleteTrackedEventsFromGoogle( - entries: Array<[string, string]>, - eventMap: Record, - getAccessToken: () => Promise, - persistProgress = false, - onProgress?: GoogleCalendarSyncOptions["onProgress"], - progressOffset = 0, - progressTotal = 0, -): Promise { - if (entries.length === 0) return { deleted: 0, failed: 0 }; - - let accessToken = await getAccessToken(); - let deleted = 0; - let failed = 0; - - for (let i = 0; i < entries.length; i++) { - const [mapKey, eventId] = entries[i]; - try { - await deleteGoogleCalendarEvent(accessToken, CALENDAR_ID, eventId, async () => { - accessToken = await getAccessToken(); - return accessToken; - }); - delete eventMap[mapKey]; - deleted += 1; - - reportSyncProgress(onProgress, { - phase: "deleting", - current: progressOffset + deleted + failed, - total: progressTotal, - message: `Removing old events (${deleted + failed}/${entries.length})…`, - }); - - if (persistProgress && (deleted + failed) % EVENT_MAP_PERSIST_EVERY === 0) { - await writeGoogleCalendarState({ eventMap }); - } - } catch (err) { - verboseLog("[BetterSEQTA+] Google Calendar event delete failed:", err); - failed += 1; - reportSyncProgress(onProgress, { - phase: "deleting", - current: progressOffset + deleted + failed, - total: progressTotal, - message: `Removing old events (${deleted + failed}/${entries.length})…`, - }); - } - } - - return { deleted, failed }; -} - -/** Runs in the content script tab so long syncs are not killed by the MV3 service worker. */ -export async function syncLessonsToGoogleCalendar( - request: GoogleCalendarSyncRequest, - getAccessToken: () => Promise, - options: GoogleCalendarSyncOptions = {}, -): Promise { - if (!isGoogleCalendarConfigured()) { - return notConfiguredSyncResult( - "Google Calendar is not configured in this extension build.", - ); - } - - const state = await readGoogleCalendarState(); - if (!state.refreshToken && !state.accessToken) { - return notConnectedSyncResult("Connect Google Calendar first."); - } - - const mode = request.mode ?? "full"; - const weeksAhead = request.weeksAhead ?? (await getSyncWeeksAhead()); - 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, - total: Math.max(events.length, 1), - message: mode === "incremental" ? "Preparing weekly sync…" : "Preparing sync…", - }); - - 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, - eventMap, - getAccessToken, - false, - options.onProgress, - 0, - totalSteps, - ); - - 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), - refreshAccessToken, - ), - writeState: writeGoogleCalendarState, - onProgress: options.onProgress, - logLabel: "Google Calendar", - }); - - await persistFinalSyncState( - writeGoogleCalendarState, - eventMap, - lastSyncAt, - request.origin, - staleResult.deleted, - staleEntries.length, - events.length, - ); - - reportSyncProgress(options.onProgress, { - phase: "done", - current: totalSteps, - total: totalSteps, - message: "Sync complete", - }); - - return buildLessonSyncResult( - upsertResult.created, - upsertResult.updated, - staleResult.deleted, - upsertResult.failed, - lastSyncAt, - ); -} - -/** Delete all tracked BetterSEQTA+ events for this SEQTA origin from Google Calendar. */ -export async function deleteSyncedEventsFromGoogleCalendar( - origin: string, - getAccessToken: () => Promise, - options: GoogleCalendarSyncOptions = {}, -): Promise { - if (!isGoogleCalendarConfigured()) { - return { - success: false, - configured: false, - error: "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." }; - } - - 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, - message: "Preparing removal…", - }); - - const eventMap = { ...(state.eventMap ?? {}) }; - const { deleted, failed } = await deleteTrackedEventsFromGoogle( - entries, - eventMap, - getAccessToken, - true, - options.onProgress, - 0, - entries.length, - ); - - await writeGoogleCalendarState({ eventMap }); - - reportSyncProgress(options.onProgress, { - phase: "done", - current: entries.length, - total: entries.length, - message: "Removal complete", - }); - - return { - success: failed === 0, - configured: true, - connected: true, - deleted, - failed, - error: - failed > 0 - ? `Removed ${deleted} event${deleted === 1 ? "" : "s"} with ${failed} error${failed === 1 ? "" : "s"}.` - : undefined, - }; -} diff --git a/src/seqta/utils/googleCalendar/syncRunner.ts b/src/seqta/utils/googleCalendar/syncRunner.ts deleted file mode 100644 index 08d21808..00000000 --- a/src/seqta/utils/googleCalendar/syncRunner.ts +++ /dev/null @@ -1,77 +0,0 @@ -import browser from "webextension-polyfill"; -import { - fetchTimetableForSync, - fetchTimetableLessons, - trailingWeekRange, -} from "@/seqta/utils/googleCalendar/fetchTimetable"; -import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; -import { reportSyncProgress } from "@/seqta/utils/calendarSync/lessonSyncShared"; -import { syncLessonsToGoogleCalendar } from "@/seqta/utils/googleCalendar/syncEngine"; -import type { - GoogleCalendarSyncOptions, - GoogleCalendarSyncProgress, - GoogleCalendarSyncResult, -} from "@/seqta/utils/googleCalendar/types"; - -export type GoogleCalendarRunMode = "full" | "incremental"; - -export interface RunGoogleCalendarSyncParams { - mode?: GoogleCalendarRunMode; - silent?: boolean; - onProgress?: (progress: GoogleCalendarSyncProgress) => void; -} - -async function getAccessTokenFromBackground(): Promise { - const res = (await browser.runtime.sendMessage({ - type: "googleCalendarGetAccessToken", - })) as { success?: boolean; accessToken?: string; error?: string }; - if (!res?.success || !res.accessToken) { - throw new Error(res?.error ?? "Could not get Google Calendar access token."); - } - return res.accessToken; -} - -export async function runGoogleCalendarSync( - params: RunGoogleCalendarSyncParams = {}, -): Promise { - const mode = params.mode ?? "full"; - const weeksAhead = await getSyncWeeksAhead(); - - reportSyncProgress(params.onProgress, { - phase: "preparing", - current: 0, - total: 1, - message: mode === "incremental" ? "Fetching new week…" : "Fetching timetable…", - }); - - const lessons = - mode === "incremental" - ? await fetchTimetableLessons(trailingWeekRange(weeksAhead)) - : await fetchTimetableForSync(weeksAhead); - - const options: GoogleCalendarSyncOptions = { onProgress: params.onProgress }; - const result = await syncLessonsToGoogleCalendar( - { - origin: location.origin, - lessons, - mode, - weeksAhead, - }, - getAccessTokenFromBackground, - options, - ); - - return result; -} - -export function formatSyncResultMessage(result: GoogleCalendarSyncResult): string { - const created = result.created ?? 0; - const updated = result.updated ?? 0; - const deleted = result.deleted ?? 0; - const parts: string[] = []; - if (created > 0) parts.push(`${created} new`); - if (updated > 0) parts.push(`${updated} updated`); - if (deleted > 0) parts.push(`${deleted} removed`); - if (parts.length === 0) return "Google Calendar is up to date."; - return `Google Calendar updated (${parts.join(", ")}).`; -} diff --git a/src/seqta/utils/googleCalendar/syncSettings.ts b/src/seqta/utils/googleCalendar/syncSettings.ts deleted file mode 100644 index 3f1ab00e..00000000 --- a/src/seqta/utils/googleCalendar/syncSettings.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { - CALENDAR_WEEKLY_ALARM as GOOGLE_CALENDAR_WEEKLY_ALARM, - WEEKLY_SYNC_INTERVAL_MS, - clampSyncWeeks, - getAutoSyncWeekly, - getSyncWeeksAhead, - markWeeklySyncComplete, - markWeeklySyncPending, - shouldRunWeeklySync, -} from "@/seqta/utils/calendarSync/settings"; diff --git a/src/seqta/utils/googleCalendar/upsertEvent.ts b/src/seqta/utils/googleCalendar/upsertEvent.ts deleted file mode 100644 index 81a0c7bb..00000000 --- a/src/seqta/utils/googleCalendar/upsertEvent.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { GOOGLE_CALENDAR_API } from "@/config/googleCalendar"; - -export async function upsertGoogleCalendarEvent( - accessToken: string, - calendarId: string, - existingEventId: string | undefined, - body: Record, - refreshAccessToken?: () => Promise, -): Promise { - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }; - - if (existingEventId) { - const res = await fetch( - `${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(existingEventId)}`, - { method: "PATCH", headers, body: JSON.stringify(body) }, - ); - if (res.status === 401 && refreshAccessToken) { - const nextToken = await refreshAccessToken(); - return upsertGoogleCalendarEvent(nextToken, calendarId, existingEventId, body); - } - if (res.ok) return existingEventId; - if (res.status !== 404) { - const err = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; - throw new Error(err?.error?.message ?? `Google Calendar update failed (${res.status})`); - } - } - - const res = await fetch( - `${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events`, - { method: "POST", headers, body: JSON.stringify(body) }, - ); - if (res.status === 401 && refreshAccessToken) { - const nextToken = await refreshAccessToken(); - return upsertGoogleCalendarEvent(nextToken, calendarId, undefined, body); - } - const json = (await res.json().catch(() => ({}))) as { id?: string; error?: { message?: string } }; - if (!res.ok || !json.id) { - throw new Error(json?.error?.message ?? `Google Calendar create failed (${res.status})`); - } - return json.id; -} - -export async function deleteGoogleCalendarEvent( - accessToken: string, - calendarId: string, - eventId: string, - refreshAccessToken?: () => Promise, -): Promise { - const res = await fetch( - `${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, - { method: "DELETE", headers: { Authorization: `Bearer ${accessToken}` } }, - ); - if (res.status === 401 && refreshAccessToken) { - const nextToken = await refreshAccessToken(); - return deleteGoogleCalendarEvent(nextToken, calendarId, eventId); - } - if (res.ok || res.status === 404 || res.status === 410) return; - const err = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; - throw new Error(err?.error?.message ?? `Google Calendar delete failed (${res.status})`); -} diff --git a/src/seqta/utils/outlookCalendar/eventMapper.ts b/src/seqta/utils/outlookCalendar/eventMapper.ts deleted file mode 100644 index 9fd490db..00000000 --- a/src/seqta/utils/outlookCalendar/eventMapper.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar"; -import type { - GoogleCalendarEventInput, - SeqtaTimetableLesson, -} from "@/seqta/utils/googleCalendar/types"; -import { - lessonToGoogleEvent, - mapLessonsToGoogleEvents, -} from "@/seqta/utils/googleCalendar/eventMapper"; - -export { mapLessonsToGoogleEvents, lessonToGoogleEvent, seqtaLessonKey } from "@/seqta/utils/googleCalendar/eventMapper"; - -export function outlookGraphEventBody(event: GoogleCalendarEventInput): Record { - const body: Record = { - subject: event.summary, - body: { - contentType: "text", - content: event.description ?? "Synced by BetterSEQTA+", - }, - start: { dateTime: event.startDateTime, timeZone: event.timeZone }, - end: { dateTime: event.endDateTime, timeZone: event.timeZone }, - categories: [BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY], - }; - if (event.location) { - body.location = { displayName: event.location }; - } - return body; -} - -export function mapLessonsToOutlookEvents( - origin: string, - lessons: SeqtaTimetableLesson[], - timeZone: string, -): GoogleCalendarEventInput[] { - return mapLessonsToGoogleEvents(origin, lessons, timeZone); -} diff --git a/src/seqta/utils/outlookCalendar/storage.ts b/src/seqta/utils/outlookCalendar/storage.ts index 1b5f5f0a..52bc20b7 100644 --- a/src/seqta/utils/outlookCalendar/storage.ts +++ b/src/seqta/utils/outlookCalendar/storage.ts @@ -1,38 +1,8 @@ -import browser from "webextension-polyfill"; -import type { GoogleCalendarEventMapEntry } from "@/seqta/utils/googleCalendar/eventMapEntry"; - -export const BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY = "bsplus_outlook_calendar"; - -export interface OutlookCalendarStoredState { - accessToken?: string; - refreshToken?: string; - expiresAt?: number; - connectedAt?: number; - lastSyncAt?: number; - lastSyncOrigin?: string; - eventMap?: Record; -} - -export async function readOutlookCalendarState(): Promise { - const got = await browser.storage.local.get(BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY); - const raw = got[BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY]; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; - return raw as OutlookCalendarStoredState; -} - -export async function writeOutlookCalendarState( - patch: Partial, -): Promise { - const current = await readOutlookCalendarState(); - const next: OutlookCalendarStoredState = { ...current, ...patch }; - await browser.storage.local.set({ [BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY]: next }); - return next; -} - -export async function clearOutlookCalendarState(): Promise { - await browser.storage.local.remove(BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY); -} - -export function outlookEventMapKey(origin: string, seqtaKey: string): string { - return `${origin}::${seqtaKey}`; -} +export { + BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY, + clearOutlookCalendarState, + readOutlookCalendarState, + writeOutlookCalendarState, + type OutlookCalendarStatus, + type OutlookCalendarStoredState, +} from "@/seqta/utils/calendarSync/providerStorage"; diff --git a/src/seqta/utils/outlookCalendar/syncEngine.ts b/src/seqta/utils/outlookCalendar/syncEngine.ts deleted file mode 100644 index 345ea1dc..00000000 --- a/src/seqta/utils/outlookCalendar/syncEngine.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { verboseLog } from "@/utils/verboseLog"; -import { isOutlookCalendarConfigured } from "@/config/outlookCalendar"; -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 type { - GoogleCalendarDeleteResult, - GoogleCalendarSyncOptions, - GoogleCalendarSyncRequest, - GoogleCalendarSyncResult, -} from "@/seqta/utils/googleCalendar/types"; -import { - mapLessonsToOutlookEvents, - outlookGraphEventBody, -} from "@/seqta/utils/outlookCalendar/eventMapper"; -import { - outlookEventMapKey, - readOutlookCalendarState, - writeOutlookCalendarState, -} from "@/seqta/utils/outlookCalendar/storage"; -import { - deleteOutlookCalendarEvent, - upsertOutlookCalendarEvent, -} from "@/seqta/utils/outlookCalendar/upsertEvent"; - -type DeleteTrackedEventsResult = { - deleted: number; - failed: number; -}; - -async function deleteTrackedEventsFromOutlook( - entries: Array<[string, string]>, - eventMap: Record, - getAccessToken: () => Promise, - persistProgress = false, - onProgress?: GoogleCalendarSyncOptions["onProgress"], - progressOffset = 0, - progressTotal = 0, -): Promise { - if (entries.length === 0) return { deleted: 0, failed: 0 }; - - let accessToken = await getAccessToken(); - let deleted = 0; - let failed = 0; - - for (const [mapKey, eventId] of entries) { - try { - await deleteOutlookCalendarEvent(accessToken, eventId, async () => { - accessToken = await getAccessToken(); - return accessToken; - }); - delete eventMap[mapKey]; - deleted += 1; - - reportSyncProgress(onProgress, { - phase: "deleting", - current: progressOffset + deleted + failed, - total: progressTotal, - message: `Removing old events (${deleted + failed}/${entries.length})…`, - }); - - if (persistProgress && (deleted + failed) % EVENT_MAP_PERSIST_EVERY === 0) { - await writeOutlookCalendarState({ eventMap }); - } - } catch (err) { - verboseLog("[BetterSEQTA+] Outlook Calendar event delete failed:", err); - failed += 1; - reportSyncProgress(onProgress, { - phase: "deleting", - current: progressOffset + deleted + failed, - total: progressTotal, - message: `Removing old events (${deleted + failed}/${entries.length})…`, - }); - } - } - - return { deleted, failed }; -} - -export async function syncLessonsToOutlookCalendar( - request: GoogleCalendarSyncRequest, - getAccessToken: () => Promise, - options: GoogleCalendarSyncOptions = {}, -): Promise { - if (!isOutlookCalendarConfigured()) { - return notConfiguredSyncResult( - "Outlook Calendar is not configured in this extension build.", - ); - } - - const state = await readOutlookCalendarState(); - if (!state.refreshToken && !state.accessToken) { - return notConnectedSyncResult("Connect Outlook Calendar first."); - } - - const mode = request.mode ?? "full"; - const weeksAhead = request.weeksAhead ?? (await getSyncWeeksAhead()); - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - const events = mapLessonsToOutlookEvents(request.origin, request.lessons, timeZone); - - if (events.length === 0 && mode === "full") { - return emptyLessonsSyncResult(); - } - - reportSyncProgress(options.onProgress, { - phase: "preparing", - current: 0, - total: Math.max(events.length, 1), - message: mode === "incremental" ? "Preparing weekly sync…" : "Preparing sync…", - }); - - 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, - eventMap, - getAccessToken, - false, - options.onProgress, - 0, - totalSteps, - ); - - 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), - refreshAccessToken, - ), - writeState: writeOutlookCalendarState, - onProgress: options.onProgress, - logLabel: "Outlook Calendar", - }); - - await persistFinalSyncState( - writeOutlookCalendarState, - eventMap, - lastSyncAt, - request.origin, - staleResult.deleted, - staleEntries.length, - events.length, - ); - - reportSyncProgress(options.onProgress, { - phase: "done", - current: totalSteps, - total: totalSteps, - message: "Sync complete", - }); - - return buildLessonSyncResult( - upsertResult.created, - upsertResult.updated, - staleResult.deleted, - upsertResult.failed, - lastSyncAt, - ); -} - -export async function deleteSyncedEventsFromOutlookCalendar( - origin: string, - getAccessToken: () => Promise, - options: GoogleCalendarSyncOptions = {}, -): Promise { - if (!isOutlookCalendarConfigured()) { - return { - success: false, - configured: false, - error: "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." }; - } - - 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, - message: "Preparing removal…", - }); - - const eventMap = { ...(state.eventMap ?? {}) }; - const { deleted, failed } = await deleteTrackedEventsFromOutlook( - entries, - eventMap, - getAccessToken, - true, - options.onProgress, - 0, - entries.length, - ); - - await writeOutlookCalendarState({ eventMap }); - - reportSyncProgress(options.onProgress, { - phase: "done", - current: entries.length, - total: entries.length, - message: "Removal complete", - }); - - return { - success: failed === 0, - configured: true, - connected: true, - deleted, - failed, - error: - failed > 0 - ? `Removed ${deleted} event${deleted === 1 ? "" : "s"} with ${failed} error${failed === 1 ? "" : "s"}.` - : undefined, - }; -} diff --git a/src/seqta/utils/outlookCalendar/syncRunner.ts b/src/seqta/utils/outlookCalendar/syncRunner.ts deleted file mode 100644 index bcea7338..00000000 --- a/src/seqta/utils/outlookCalendar/syncRunner.ts +++ /dev/null @@ -1,77 +0,0 @@ -import browser from "webextension-polyfill"; -import { - fetchTimetableForSync, - fetchTimetableLessons, - trailingWeekRange, -} from "@/seqta/utils/googleCalendar/fetchTimetable"; -import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; -import { reportSyncProgress } from "@/seqta/utils/calendarSync/lessonSyncShared"; -import { syncLessonsToOutlookCalendar } from "@/seqta/utils/outlookCalendar/syncEngine"; -import type { - GoogleCalendarSyncOptions, - GoogleCalendarSyncProgress, - GoogleCalendarSyncResult, -} from "@/seqta/utils/googleCalendar/types"; - -export type OutlookCalendarRunMode = "full" | "incremental"; - -export interface RunOutlookCalendarSyncParams { - mode?: OutlookCalendarRunMode; - silent?: boolean; - onProgress?: (progress: GoogleCalendarSyncProgress) => void; -} - -async function getAccessTokenFromBackground(): Promise { - const res = (await browser.runtime.sendMessage({ - type: "outlookCalendarGetAccessToken", - })) as { success?: boolean; accessToken?: string; error?: string }; - if (!res?.success || !res.accessToken) { - throw new Error(res?.error ?? "Could not get Outlook Calendar access token."); - } - return res.accessToken; -} - -export async function runOutlookCalendarSync( - params: RunOutlookCalendarSyncParams = {}, -): Promise { - const mode = params.mode ?? "full"; - const weeksAhead = await getSyncWeeksAhead(); - - reportSyncProgress(params.onProgress, { - phase: "preparing", - current: 0, - total: 1, - message: mode === "incremental" ? "Fetching new week…" : "Fetching timetable…", - }); - - const lessons = - mode === "incremental" - ? await fetchTimetableLessons(trailingWeekRange(weeksAhead)) - : await fetchTimetableForSync(weeksAhead); - - const options: GoogleCalendarSyncOptions = { onProgress: params.onProgress }; - const result = await syncLessonsToOutlookCalendar( - { - origin: location.origin, - lessons, - mode, - weeksAhead, - }, - getAccessTokenFromBackground, - options, - ); - - return result; -} - -export function formatOutlookSyncResultMessage(result: GoogleCalendarSyncResult): string { - const created = result.created ?? 0; - const updated = result.updated ?? 0; - const deleted = result.deleted ?? 0; - const parts: string[] = []; - if (created > 0) parts.push(`${created} new`); - if (updated > 0) parts.push(`${updated} updated`); - if (deleted > 0) parts.push(`${deleted} removed`); - if (parts.length === 0) return "Outlook Calendar is up to date."; - return `Outlook Calendar updated (${parts.join(", ")}).`; -} diff --git a/src/seqta/utils/outlookCalendar/types.ts b/src/seqta/utils/outlookCalendar/types.ts deleted file mode 100644 index 75b1a23c..00000000 --- a/src/seqta/utils/outlookCalendar/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface OutlookCalendarStatus { - configured: boolean; - connected: boolean; - lastSyncAt?: number; - lastSyncOrigin?: string; -} diff --git a/src/seqta/utils/outlookCalendar/upsertEvent.test.ts b/src/seqta/utils/outlookCalendar/upsertEvent.test.ts index 410075e0..b3f284e1 100644 --- a/src/seqta/utils/outlookCalendar/upsertEvent.test.ts +++ b/src/seqta/utils/outlookCalendar/upsertEvent.test.ts @@ -1,48 +1,31 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; -import { upsertOutlookCalendarEvent, deleteOutlookCalendarEvent } from "./upsertEvent"; -const fetchMock = jest.fn(); -global.fetch = fetchMock as unknown as typeof fetch; +const mockFetch = jest.fn(); +global.fetch = mockFetch as typeof fetch; -describe("upsertOutlookCalendarEvent", () => { +import { + deleteOutlookCalendarEvent, + upsertOutlookCalendarEvent, +} from "@/seqta/utils/calendarSync/remoteEvents"; + +describe("outlook calendar remote events", () => { beforeEach(() => { - fetchMock.mockReset(); + mockFetch.mockReset(); }); - it("creates a new event when no existing id", async () => { - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ id: "evt-1" }), - }); + it("creates a new event when none exists", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: "evt-1" }), { status: 201 }), + ); const id = await upsertOutlookCalendarEvent("token", undefined, { subject: "Math" }); expect(id).toBe("evt-1"); - expect(fetchMock).toHaveBeenCalledWith( - "https://graph.microsoft.com/v1.0/me/events", - expect.objectContaining({ method: "POST" }), - ); + expect(mockFetch).toHaveBeenCalledTimes(1); }); - it("patches when an existing id is provided", async () => { - fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); + it("deletes an event", async () => { + mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); - const id = await upsertOutlookCalendarEvent("token", "evt-1", { subject: "Math" }); - expect(id).toBe("evt-1"); - expect(fetchMock).toHaveBeenCalledWith( - "https://graph.microsoft.com/v1.0/me/events/evt-1", - expect.objectContaining({ method: "PATCH" }), - ); - }); -}); - -describe("deleteOutlookCalendarEvent", () => { - beforeEach(() => { - fetchMock.mockReset(); - }); - - it("treats 404 as success", async () => { - fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) }); await expect(deleteOutlookCalendarEvent("token", "evt-1")).resolves.toBeUndefined(); }); }); diff --git a/src/seqta/utils/outlookCalendar/upsertEvent.ts b/src/seqta/utils/outlookCalendar/upsertEvent.ts deleted file mode 100644 index 1c5fc182..00000000 --- a/src/seqta/utils/outlookCalendar/upsertEvent.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { OUTLOOK_GRAPH_API } from "@/config/outlookCalendar"; - -export async function upsertOutlookCalendarEvent( - accessToken: string, - existingEventId: string | undefined, - body: Record, - refreshAccessToken?: () => Promise, -): Promise { - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }; - - if (existingEventId) { - const res = await fetch(`${OUTLOOK_GRAPH_API}/me/events/${encodeURIComponent(existingEventId)}`, { - method: "PATCH", - headers, - body: JSON.stringify(body), - }); - if (res.status === 401 && refreshAccessToken) { - const nextToken = await refreshAccessToken(); - return upsertOutlookCalendarEvent(nextToken, existingEventId, body); - } - if (res.ok) return existingEventId; - if (res.status !== 404) { - const err = (await res.json().catch(() => ({}))) as { - error?: { message?: string }; - }; - throw new Error(err?.error?.message ?? `Outlook Calendar update failed (${res.status})`); - } - } - - const res = await fetch(`${OUTLOOK_GRAPH_API}/me/events`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - if (res.status === 401 && refreshAccessToken) { - const nextToken = await refreshAccessToken(); - return upsertOutlookCalendarEvent(nextToken, undefined, body); - } - const json = (await res.json().catch(() => ({}))) as { - id?: string; - error?: { message?: string }; - }; - if (!res.ok || !json.id) { - throw new Error(json?.error?.message ?? `Outlook Calendar create failed (${res.status})`); - } - return json.id; -} - -export async function deleteOutlookCalendarEvent( - accessToken: string, - eventId: string, - refreshAccessToken?: () => Promise, -): Promise { - const res = await fetch(`${OUTLOOK_GRAPH_API}/me/events/${encodeURIComponent(eventId)}`, { - method: "DELETE", - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (res.status === 401 && refreshAccessToken) { - const nextToken = await refreshAccessToken(); - return deleteOutlookCalendarEvent(nextToken, eventId); - } - if (res.ok || res.status === 404 || res.status === 410) return; - const err = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; - throw new Error(err?.error?.message ?? `Outlook Calendar delete failed (${res.status})`); -} diff --git a/src/test/mocks/color.ts b/src/test/mocks/color.ts deleted file mode 100644 index 585602a5..00000000 --- a/src/test/mocks/color.ts +++ /dev/null @@ -1,86 +0,0 @@ -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; diff --git a/src/utils/verboseLog.ts b/src/utils/verboseLog.ts index 9d77f3f4..85616c2c 100644 --- a/src/utils/verboseLog.ts +++ b/src/utils/verboseLog.ts @@ -1,38 +1,5 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState"; -const VERBOSE_LOG_ATTR = "data-bsplus-verbose-log"; - -export function isVerboseLoggingEnabled(): boolean { - return Boolean(settingsState.devMode && settingsState.verboseLogging); -} - -export function syncVerboseLogDomFlag(): void { - if (typeof document === "undefined") return; - document.documentElement.toggleAttribute( - VERBOSE_LOG_ATTR, - isVerboseLoggingEnabled(), - ); -} - -let initialized = false; - -/** Register DOM flag sync when dev / verbose toggles change. Call after settings load. */ -export function initVerboseLogging(): void { - if (initialized) return; - initialized = true; - syncVerboseLogDomFlag(); - settingsState.register("devMode", () => syncVerboseLogDomFlag()); - settingsState.register("verboseLogging", () => syncVerboseLogDomFlag()); -} - -export function verboseDebug(...args: unknown[]): void { - if (isVerboseLoggingEnabled()) console.debug(...args); -} - -export function verboseInfo(...args: unknown[]): void { - if (isVerboseLoggingEnabled()) console.info(...args); -} - export function verboseLog(...args: unknown[]): void { - if (isVerboseLoggingEnabled()) console.log(...args); + if (settingsState.devMode) console.log(...args); }