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/README.md b/README.md index 8b8c83fa..463a3c5c 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@

- A beautiful 🤩 Chrome Extension that adds additional features and gives an overall better experience for SEQTA Learn. Currently looking for contributors 🔥 + A beautiful 🤩 browser extension that transforms SEQTA with modern themes, powerful customization, productivity tools, and an improved user experience. Contributions are always welcome!

- ChromeDownload + ChromeDownload

@@ -16,15 +16,16 @@ -## 📚 Documentation -All documentation has been moved to the [official docs site](https://docs.betterseqta.org): +## Documentation + +Everything you need to get started with BetterSEQTA+ can be found in the [official documentation](https://docs.betterseqta.org). Includes: -- Getting started -- Development setup +- Getting Started +- Installation & Development - Architecture -- Plugin system -- Theme creation +- Plugin System +- Theme Creation ## Features @@ -72,8 +73,7 @@ npm run dev Then load `dist` in `chrome://extensions` (Developer Mode → Load unpacked). -Full setup guide: -[https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment](https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment) +Full development setup guide can be found [here](https://docs.betterseqta.org/install/#for-developers-development-environment). ## Contributors @@ -85,10 +85,9 @@ Want to contribute? [Click Here!](https://docs.betterseqta.org/contributing/) ## Credits -This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers. -Originally developed by [Nulkem](https://github.com/Nulkem/betterseqta) -Ported to Manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) -Maintained by [SethBurkart123](https://github.com/SethBurkart123), [Crazypersonalph](https://github.com/Crazypersonalph) & the rest of the BetterSEQTA team! +Originally developed by [Nulkem](https://github.com/Nulkem/betterseqta) +Ported to Manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) +Maintained by [SethBurkart123](https://github.com/SethBurkart123), [Crazypersonalph](https://github.com/Crazypersonalph) & the rest of the BetterSEQTA team! ## Star History diff --git a/SECURITY.md b/SECURITY.md index f884bbf3..d8e4e370 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,15 +2,15 @@ ## Supported Versions -Below here is the supported versions of BetterSEQTA+. Anything older than this is not supported and contains bugs. +The following versions of BetterSEQTA+ are currently supported. | Version | Supported | | ------- | --------- | -| 3.4.3 | ✅ | -| < 3.4.3 | :x: | - -`*` May not work on other devices. +| 3.4.3 or later | ✅ | +| Earlier than 3.4.3 | ❌ | ## Reporting a Vulnerability -If you find vulnerabilities, REPORT IT IMMEDIATELY. open the [advisories tab](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories) on the left and click the green "report a vulnerability" button or use [this quick-link](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories/new) to create a new report +If you discover a security vulnerability, please report it responsibly through GitHub Security Advisories: + +https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories/new diff --git a/jest.config.js b/jest.config.js index a63ca1c1..b581d02a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,7 +15,6 @@ export default { ], moduleNameMapper: { '^@/(.*)$': '/src/$1', - '^color$': '/src/test/mocks/color.ts', '^webextension-polyfill$': '/src/test/mocks/webextension-polyfill.ts', }, setupFilesAfterEnv: ['/src/test/jest.setup.ts'], diff --git a/src/background.ts b/src/background.ts index 779fef38..e4110da5 100644 --- a/src/background.ts +++ b/src/background.ts @@ -14,8 +14,11 @@ import { } from "./background/cloudSettingsAutoSync"; import { getBsplusDeviceName } from "@/seqta/utils/bsplusDeviceName"; 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. @@ -570,7 +573,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..d01df6e7 --- /dev/null +++ b/src/background/calendarBackground.ts @@ -0,0 +1,544 @@ +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 { ensureGoogleAppCalendar } from "@/seqta/utils/googleCalendar/calendarProvisioning"; +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; + afterConnect?: (accessToken: string) => Promise>; +}; + +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, + async afterConnect(accessToken) { + const state = await readGoogleCalendarState(); + const calendarId = await ensureGoogleAppCalendar(accessToken, state.calendarId); + return { calendarId }; + }, +}); + +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(), + }); + if (provider.afterConnect) { + const patch = await provider.afterConnect(tokens.access_token); + if (patch && Object.keys(patch).length > 0) { + await provider.write(patch); + } + } + 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..ba25f968 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,22 +11,23 @@ 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`; -export const GOOGLE_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.events"; +/** App-owned secondary calendar only — cannot read or modify the user's other calendars. */ +export const GOOGLE_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.app.created"; export const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; export const GOOGLE_CALENDAR_API = "https://www.googleapis.com/calendar/v3"; +export const BSPLUS_GOOGLE_CALENDAR_NAME = "BetterSEQTA+ Timetable"; +export const BSPLUS_GOOGLE_CALENDAR_DESCRIPTION = + "School timetable lessons synced by the BetterSEQTA+ extension."; + 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 7496419f..ccc75d91 100644 --- a/src/lib/extensionAssetUrl.ts +++ b/src/lib/extensionAssetUrl.ts @@ -1,19 +1,10 @@ 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 585fad24..00000000 --- a/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte +++ /dev/null @@ -1,142 +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 3a6f7076..00000000 --- a/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte +++ /dev/null @@ -1,142 +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 63f8809b..4572f569 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} +
{#if menuOpen} @@ -420,229 +517,144 @@ role="menu" bind:this={menuEl} style={menuStyle} - use:portalMenu + use:portalToBody transition:fly={{ y: -6, duration: 160 }} >
Calendar sync - Connect providers to sync your timetable + Copy your SEQTA timetable classes to Google or Outlook
-
-
- -
- - - Google - - Calendar - - - {#if !googleStatus.configured} - Not available in this build - {:else if googleStatus.connected} - Connected{formatLastSync(googleStatus.lastSyncAt) ? ` · ${formatLastSync(googleStatus.lastSyncAt)}` : ""} - {:else} - Not connected - {/if} - -
-
- -
- {#if !googleStatus.connected} - - {: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} -
- - -

- Syncs {syncWeeksAhead} weeks ahead on connect and manual sync. Weekly auto-sync adds each new week forward. -

-
- {/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 c27f092d..e0e3da77 100644 --- a/src/plugins/built-in/timetable/calendarSyncHost.css +++ b/src/plugins/built-in/timetable/calendarSyncHost.css @@ -11,14 +11,18 @@ } .timetable-calendar-controls.bsplus-cal-menu-open { - z-index: 2147483646; + z-index: 2147483645; } .timetablepage #toolbar:has(.bsplus-cal-menu-open) { - z-index: 2147483646 !important; + z-index: 2147483645 !important; +} + +:root { + --bsplus-cal-z-menu: 2147483646; + --bsplus-cal-z-modal: 2147483647; } .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/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/public/resources/ort/ort-wasm-simd-threaded.jsep.mjs b/src/public/resources/ort/ort-wasm-simd-threaded.jsep.mjs new file mode 100644 index 00000000..fd68c633 --- /dev/null +++ b/src/public/resources/ort/ort-wasm-simd-threaded.jsep.mjs @@ -0,0 +1,125 @@ +var ortWasmThreaded = (() => { + var _scriptName = import.meta.url; + + return ( +async function(moduleArg = {}) { + var moduleRtn; + +var e=moduleArg,aa,ca,da=new Promise((a,b)=>{aa=a;ca=b}),ea="object"==typeof window,k="undefined"!=typeof WorkerGlobalScope,n="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node&&"renderer"!=process.type,q=k&&self.name?.startsWith("em-pthread");if(n){const {createRequire:a}=await import("module");var require=a(import.meta.url),fa=require("worker_threads");global.Worker=fa.Worker;q=(k=!fa.oc)&&"em-pthread"==fa.workerData} +e.mountExternalData=(a,b)=>{a.startsWith("./")&&(a=a.substring(2));(e.Eb||(e.Eb=new Map)).set(a,b)};e.unmountExternalData=()=>{delete e.Eb};var SharedArrayBuffer=globalThis.SharedArrayBuffer??(new WebAssembly.Memory({initial:0,maximum:0,pc:!0})).buffer.constructor; +const ha=a=>async(...b)=>{try{if(e.Fb)throw Error("Session already started");const c=e.Fb={dc:b[0],errors:[]},d=await a(...b);if(e.Fb!==c)throw Error("Session mismatch");e.Jb?.flush();const f=c.errors;if(0h);if(0{if("webgpu"===a){[e.Jb,e.Ub,e.Yb,e.Kb,e.Xb,e.jb,e.Zb,e.ac,e.Vb,e.Wb,e.$b]=b;const c=e.Jb;e.jsepRegisterBuffer=(d,f,g,h)=>c.registerBuffer(d,f,g,h);e.jsepGetBuffer=d=>c.getBuffer(d);e.jsepCreateDownloader=(d,f,g)=>c.createDownloader(d,f,g);e.jsepOnCreateSession=d=>{c.onCreateSession(d)};e.jsepOnReleaseSession=d=>{c.onReleaseSession(d)};e.jsepOnRunStart=d=>c.onRunStart(d);e.bc=(d,f)=>{c.upload(d,f)}}else if("webnn"===a){const c=b[0];[e.nc,e.Nb,e.webnnEnsureTensor,e.Ob,e.webnnDownloadTensor]= +b.slice(1);e.webnnReleaseTensorId=e.Nb;e.webnnUploadTensor=e.Ob;e.webnnOnRunStart=d=>c.onRunStart(d);e.webnnOnRunEnd=c.onRunEnd.bind(c);e.webnnRegisterMLContext=(d,f)=>{c.registerMLContext(d,f)};e.webnnOnReleaseSession=d=>{c.onReleaseSession(d)};e.webnnCreateMLTensorDownloader=(d,f)=>c.createMLTensorDownloader(d,f);e.webnnRegisterMLTensor=(d,f,g,h)=>c.registerMLTensor(d,f,g,h);e.webnnCreateMLContext=d=>c.createMLContext(d);e.webnnRegisterMLConstant=(d,f,g,h,l,m)=>c.registerMLConstant(d,f,g,h,l,e.Eb, +m);e.webnnRegisterGraphInput=c.registerGraphInput.bind(c);e.webnnIsGraphInput=c.isGraphInput.bind(c);e.webnnCreateTemporaryTensor=c.createTemporaryTensor.bind(c);e.webnnIsInt64Supported=c.isInt64Supported.bind(c)}}; +let ja=()=>{const a=(b,c,d)=>(...f)=>{const g=t,h=c?.();f=b(...f);const l=c?.();h!==l&&(b=l,d(h),c=d=null);return t!=g?ia():f};(b=>{for(const c of b)e[c]=a(e[c],()=>e[c],d=>e[c]=d)})(["_OrtAppendExecutionProvider","_OrtCreateSession","_OrtRun","_OrtRunWithBinding","_OrtBindInput"]);"undefined"!==typeof ha&&(e._OrtRun=ha(e._OrtRun),e._OrtRunWithBinding=ha(e._OrtRunWithBinding));ja=void 0};e.asyncInit=()=>{ja?.()};var ka=Object.assign({},e),la="./this.program",ma=(a,b)=>{throw b;},v="",na,oa; +if(n){var fs=require("fs"),pa=require("path");import.meta.url.startsWith("data:")||(v=pa.dirname(require("url").fileURLToPath(import.meta.url))+"/");oa=a=>{a=qa(a)?new URL(a):a;return fs.readFileSync(a)};na=async a=>{a=qa(a)?new URL(a):a;return fs.readFileSync(a,void 0)};!e.thisProgram&&1{process.exitCode=a;throw b;}}else if(ea||k)k?v=self.location.href:"undefined"!=typeof document&& +document.currentScript&&(v=document.currentScript.src),_scriptName&&(v=_scriptName),v.startsWith("blob:")?v="":v=v.slice(0,v.replace(/[?#].*/,"").lastIndexOf("/")+1),n||(k&&(oa=a=>{var b=new XMLHttpRequest;b.open("GET",a,!1);b.responseType="arraybuffer";b.send(null);return new Uint8Array(b.response)}),na=async a=>{if(qa(a))return new Promise((c,d)=>{var f=new XMLHttpRequest;f.open("GET",a,!0);f.responseType="arraybuffer";f.onload=()=>{200==f.status||0==f.status&&f.response?c(f.response):d(f.status)}; +f.onerror=d;f.send(null)});var b=await fetch(a,{credentials:"same-origin"});if(b.ok)return b.arrayBuffer();throw Error(b.status+" : "+b.url);});var ra=console.log.bind(console),sa=console.error.bind(console);n&&(ra=(...a)=>fs.writeSync(1,a.join(" ")+"\n"),sa=(...a)=>fs.writeSync(2,a.join(" ")+"\n"));var ta=ra,x=sa;Object.assign(e,ka);ka=null;var ua=e.wasmBinary,z,va,A=!1,wa,B,xa,ya,za,Aa,Ba,Ca,C,Da,Ea,qa=a=>a.startsWith("file://");function D(){z.buffer!=B.buffer&&E();return B} +function F(){z.buffer!=B.buffer&&E();return xa}function G(){z.buffer!=B.buffer&&E();return ya}function Fa(){z.buffer!=B.buffer&&E();return za}function H(){z.buffer!=B.buffer&&E();return Aa}function I(){z.buffer!=B.buffer&&E();return Ba}function Ga(){z.buffer!=B.buffer&&E();return Ca}function J(){z.buffer!=B.buffer&&E();return Ea} +if(q){var Ha;if(n){var Ia=fa.parentPort;Ia.on("message",b=>onmessage({data:b}));Object.assign(globalThis,{self:global,postMessage:b=>Ia.postMessage(b)})}var Ja=!1;x=function(...b){b=b.join(" ");n?fs.writeSync(2,b+"\n"):console.error(b)};self.alert=function(...b){postMessage({Bb:"alert",text:b.join(" "),ic:Ka()})};self.onunhandledrejection=b=>{throw b.reason||b;};function a(b){try{var c=b.data,d=c.Bb;if("load"===d){let f=[];self.onmessage=g=>f.push(g);self.startWorker=()=>{postMessage({Bb:"loaded"}); +for(let g of f)a(g);self.onmessage=a};for(const g of c.Rb)if(!e[g]||e[g].proxy)e[g]=(...h)=>{postMessage({Bb:"callHandler",Qb:g,args:h})},"print"==g&&(ta=e[g]),"printErr"==g&&(x=e[g]);z=c.kc;E();Ha(c.lc)}else if("run"===d){La(c.Ab);Ma(c.Ab,0,0,1,0,0);Na();Oa(c.Ab);Ja||(Pa(),Ja=!0);try{Qa(c.fc,c.Hb)}catch(f){if("unwind"!=f)throw f;}}else"setimmediate"!==c.target&&("checkMailbox"===d?Ja&&Ra():d&&(x(`worker: received unknown command ${d}`),x(c)))}catch(f){throw Sa(),f;}}self.onmessage=a} +function E(){var a=z.buffer;e.HEAP8=B=new Int8Array(a);e.HEAP16=ya=new Int16Array(a);e.HEAPU8=xa=new Uint8Array(a);e.HEAPU16=za=new Uint16Array(a);e.HEAP32=Aa=new Int32Array(a);e.HEAPU32=Ba=new Uint32Array(a);e.HEAPF32=Ca=new Float32Array(a);e.HEAPF64=Ea=new Float64Array(a);e.HEAP64=C=new BigInt64Array(a);e.HEAPU64=Da=new BigUint64Array(a)}q||(z=new WebAssembly.Memory({initial:256,maximum:65536,shared:!0}),E());function Ta(){q?startWorker(e):K.Ca()}var Ua=0,Va=null; +function Wa(){Ua--;if(0==Ua&&Va){var a=Va;Va=null;a()}}function L(a){a="Aborted("+a+")";x(a);A=!0;a=new WebAssembly.RuntimeError(a+". Build with -sASSERTIONS for more info.");ca(a);throw a;}var Xa;async function Ya(a){if(!ua)try{var b=await na(a);return new Uint8Array(b)}catch{}if(a==Xa&&ua)a=new Uint8Array(ua);else if(oa)a=oa(a);else throw"both async and sync fetching of the wasm failed";return a} +async function Za(a,b){try{var c=await Ya(a);return await WebAssembly.instantiate(c,b)}catch(d){x(`failed to asynchronously prepare wasm: ${d}`),L(d)}}async function $a(a){var b=Xa;if(!ua&&"function"==typeof WebAssembly.instantiateStreaming&&!qa(b)&&!n)try{var c=fetch(b,{credentials:"same-origin"});return await WebAssembly.instantiateStreaming(c,a)}catch(d){x(`wasm streaming compile failed: ${d}`),x("falling back to ArrayBuffer instantiation")}return Za(b,a)} +function ab(){bb={L:cb,Aa:db,b:eb,$:fb,A:gb,pa:hb,X:ib,Z:jb,qa:kb,na:lb,ga:mb,ma:nb,J:ob,Y:pb,V:qb,oa:rb,W:sb,va:tb,E:ub,Q:vb,O:wb,D:xb,u:yb,r:zb,P:Ab,z:Bb,R:Cb,ja:Db,T:Eb,aa:Fb,M:Gb,F:Hb,ia:Oa,sa:Ib,t:Jb,Ba:Kb,w:Lb,o:Mb,l:Nb,c:Ob,n:Pb,j:Qb,v:Rb,p:Sb,f:Tb,s:Ub,m:Vb,e:Wb,k:Xb,i:Yb,g:Zb,d:$b,da:ac,ea:bc,fa:cc,ba:dc,ca:ec,N:fc,xa:gc,ua:hc,h:ic,C:jc,G:kc,ta:lc,x:mc,ra:nc,U:oc,q:pc,y:qc,K:rc,S:sc,za:tc,ya:uc,ka:vc,la:wc,_:xc,B:yc,I:zc,ha:Ac,H:Bc,a:z,wa:Cc};return{a:bb}} +var Dc={829644:(a,b,c,d,f)=>{if("undefined"==typeof e||!e.Eb)return 1;a=M(Number(a>>>0));a.startsWith("./")&&(a=a.substring(2));a=e.Eb.get(a);if(!a)return 2;b=Number(b>>>0);c=Number(c>>>0);d=Number(d>>>0);if(b+c>a.byteLength)return 3;try{const g=a.subarray(b,b+c);switch(f){case 0:F().set(g,d>>>0);break;case 1:e.mc?e.mc(d,g):e.bc(d,g);break;default:return 4}return 0}catch{return 4}},830468:(a,b,c)=>{e.Ob(a,F().subarray(b>>>0,b+c>>>0))},830532:()=>e.nc(),830574:a=>{e.Nb(a)},830611:()=>{e.Vb()},830642:()=> +{e.Wb()},830671:()=>{e.$b()},830696:a=>e.Ub(a),830729:a=>e.Yb(a),830761:(a,b,c)=>{e.Kb(Number(a),Number(b),Number(c),!0)},830824:(a,b,c)=>{e.Kb(Number(a),Number(b),Number(c))},830881:()=>"undefined"!==typeof wasmOffsetConverter,830938:a=>{e.jb("Abs",a,void 0)},830989:a=>{e.jb("Neg",a,void 0)},831040:a=>{e.jb("Floor",a,void 0)},831093:a=>{e.jb("Ceil",a,void 0)},831145:a=>{e.jb("Reciprocal",a,void 0)},831203:a=>{e.jb("Sqrt",a,void 0)},831255:a=>{e.jb("Exp",a,void 0)},831306:a=>{e.jb("Erf",a,void 0)}, +831357:a=>{e.jb("Sigmoid",a,void 0)},831412:(a,b,c)=>{e.jb("HardSigmoid",a,{alpha:b,beta:c})},831491:a=>{e.jb("Log",a,void 0)},831542:a=>{e.jb("Sin",a,void 0)},831593:a=>{e.jb("Cos",a,void 0)},831644:a=>{e.jb("Tan",a,void 0)},831695:a=>{e.jb("Asin",a,void 0)},831747:a=>{e.jb("Acos",a,void 0)},831799:a=>{e.jb("Atan",a,void 0)},831851:a=>{e.jb("Sinh",a,void 0)},831903:a=>{e.jb("Cosh",a,void 0)},831955:a=>{e.jb("Asinh",a,void 0)},832008:a=>{e.jb("Acosh",a,void 0)},832061:a=>{e.jb("Atanh",a,void 0)}, +832114:a=>{e.jb("Tanh",a,void 0)},832166:a=>{e.jb("Not",a,void 0)},832217:(a,b,c)=>{e.jb("Clip",a,{min:b,max:c})},832286:a=>{e.jb("Clip",a,void 0)},832338:(a,b)=>{e.jb("Elu",a,{alpha:b})},832396:a=>{e.jb("Gelu",a,void 0)},832448:a=>{e.jb("Relu",a,void 0)},832500:(a,b)=>{e.jb("LeakyRelu",a,{alpha:b})},832564:(a,b)=>{e.jb("ThresholdedRelu",a,{alpha:b})},832634:(a,b)=>{e.jb("Cast",a,{to:b})},832692:a=>{e.jb("Add",a,void 0)},832743:a=>{e.jb("Sub",a,void 0)},832794:a=>{e.jb("Mul",a,void 0)},832845:a=> +{e.jb("Div",a,void 0)},832896:a=>{e.jb("Pow",a,void 0)},832947:a=>{e.jb("Equal",a,void 0)},833E3:a=>{e.jb("Greater",a,void 0)},833055:a=>{e.jb("GreaterOrEqual",a,void 0)},833117:a=>{e.jb("Less",a,void 0)},833169:a=>{e.jb("LessOrEqual",a,void 0)},833228:(a,b,c,d,f)=>{e.jb("ReduceMean",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},833403:(a,b,c,d,f)=>{e.jb("ReduceMax",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>> +0,Number(f)>>>0)):[]})},833577:(a,b,c,d,f)=>{e.jb("ReduceMin",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},833751:(a,b,c,d,f)=>{e.jb("ReduceProd",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},833926:(a,b,c,d,f)=>{e.jb("ReduceSum",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},834100:(a,b,c,d,f)=>{e.jb("ReduceL1",a,{keepDims:!!b, +noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},834273:(a,b,c,d,f)=>{e.jb("ReduceL2",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},834446:(a,b,c,d,f)=>{e.jb("ReduceLogSum",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},834623:(a,b,c,d,f)=>{e.jb("ReduceSumSquare",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>> +0,Number(f)>>>0)):[]})},834803:(a,b,c,d,f)=>{e.jb("ReduceLogSumExp",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},834983:a=>{e.jb("Where",a,void 0)},835036:(a,b,c)=>{e.jb("Transpose",a,{perm:b?Array.from(H().subarray(Number(b)>>>0,Number(c)>>>0)):[]})},835160:(a,b,c,d)=>{e.jb("DepthToSpace",a,{blocksize:b,mode:M(c),format:d?"NHWC":"NCHW"})},835293:(a,b,c,d)=>{e.jb("DepthToSpace",a,{blocksize:b,mode:M(c),format:d?"NHWC":"NCHW"})},835426:(a, +b,c,d,f,g,h,l,m,p,r,u,w,y,ba)=>{e.jb("ConvTranspose",a,{format:m?"NHWC":"NCHW",autoPad:b,dilations:[c],group:d,kernelShape:[f],pads:[g,h],strides:[l],wIsConst:()=>!!D()[p>>>0],outputPadding:r?Array.from(H().subarray(Number(r)>>>0,Number(u)>>>0)):[],outputShape:w?Array.from(H().subarray(Number(w)>>>0,Number(y)>>>0)):[],activation:M(ba)})},835859:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.jb("ConvTranspose",a,{format:l?"NHWC":"NCHW",autoPad:b,dilations:Array.from(H().subarray(Number(c)>>>0,(Number(c)>>>0)+2>>> +0)),group:d,kernelShape:Array.from(H().subarray(Number(f)>>>0,(Number(f)>>>0)+2>>>0)),pads:Array.from(H().subarray(Number(g)>>>0,(Number(g)>>>0)+4>>>0)),strides:Array.from(H().subarray(Number(h)>>>0,(Number(h)>>>0)+2>>>0)),wIsConst:()=>!!D()[m>>>0],outputPadding:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],outputShape:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[],activation:M(y)})},836520:(a,b,c,d,f,g,h,l,m,p,r,u,w,y,ba)=>{e.jb("ConvTranspose",a,{format:m?"NHWC":"NCHW", +autoPad:b,dilations:[c],group:d,kernelShape:[f],pads:[g,h],strides:[l],wIsConst:()=>!!D()[p>>>0],outputPadding:r?Array.from(H().subarray(Number(r)>>>0,Number(u)>>>0)):[],outputShape:w?Array.from(H().subarray(Number(w)>>>0,Number(y)>>>0)):[],activation:M(ba)})},836953:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.jb("ConvTranspose",a,{format:l?"NHWC":"NCHW",autoPad:b,dilations:Array.from(H().subarray(Number(c)>>>0,(Number(c)>>>0)+2>>>0)),group:d,kernelShape:Array.from(H().subarray(Number(f)>>>0,(Number(f)>>>0)+ +2>>>0)),pads:Array.from(H().subarray(Number(g)>>>0,(Number(g)>>>0)+4>>>0)),strides:Array.from(H().subarray(Number(h)>>>0,(Number(h)>>>0)+2>>>0)),wIsConst:()=>!!D()[m>>>0],outputPadding:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],outputShape:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[],activation:M(y)})},837614:(a,b)=>{e.jb("GlobalAveragePool",a,{format:b?"NHWC":"NCHW"})},837705:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.jb("AveragePool",a,{format:y?"NHWC":"NCHW",auto_pad:b,ceil_mode:c, +count_include_pad:d,storage_order:f,dilations:g?Array.from(H().subarray(Number(g)>>>0,Number(h)>>>0)):[],kernel_shape:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],pads:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],strides:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[]})},838184:(a,b)=>{e.jb("GlobalAveragePool",a,{format:b?"NHWC":"NCHW"})},838275:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.jb("AveragePool",a,{format:y?"NHWC":"NCHW",auto_pad:b,ceil_mode:c,count_include_pad:d, +storage_order:f,dilations:g?Array.from(H().subarray(Number(g)>>>0,Number(h)>>>0)):[],kernel_shape:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],pads:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],strides:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[]})},838754:(a,b)=>{e.jb("GlobalMaxPool",a,{format:b?"NHWC":"NCHW"})},838841:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.jb("MaxPool",a,{format:y?"NHWC":"NCHW",auto_pad:b,ceil_mode:c,count_include_pad:d,storage_order:f,dilations:g? +Array.from(H().subarray(Number(g)>>>0,Number(h)>>>0)):[],kernel_shape:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],pads:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],strides:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[]})},839316:(a,b)=>{e.jb("GlobalMaxPool",a,{format:b?"NHWC":"NCHW"})},839403:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.jb("MaxPool",a,{format:y?"NHWC":"NCHW",auto_pad:b,ceil_mode:c,count_include_pad:d,storage_order:f,dilations:g?Array.from(H().subarray(Number(g)>>> +0,Number(h)>>>0)):[],kernel_shape:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],pads:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],strides:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[]})},839878:(a,b,c,d,f)=>{e.jb("Gemm",a,{alpha:b,beta:c,transA:d,transB:f})},839982:a=>{e.jb("MatMul",a,void 0)},840036:(a,b,c,d)=>{e.jb("ArgMax",a,{keepDims:!!b,selectLastIndex:!!c,axis:d})},840144:(a,b,c,d)=>{e.jb("ArgMin",a,{keepDims:!!b,selectLastIndex:!!c,axis:d})},840252:(a, +b)=>{e.jb("Softmax",a,{axis:b})},840315:(a,b)=>{e.jb("Concat",a,{axis:b})},840375:(a,b,c,d,f)=>{e.jb("Split",a,{axis:b,numOutputs:c,splitSizes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},840531:a=>{e.jb("Expand",a,void 0)},840585:(a,b)=>{e.jb("Gather",a,{axis:Number(b)})},840656:(a,b)=>{e.jb("GatherElements",a,{axis:Number(b)})},840735:(a,b)=>{e.jb("GatherND",a,{batch_dims:Number(b)})},840814:(a,b,c,d,f,g,h,l,m,p,r)=>{e.jb("Resize",a,{antialias:b,axes:c?Array.from(H().subarray(Number(c)>>> +0,Number(d)>>>0)):[],coordinateTransformMode:M(f),cubicCoeffA:g,excludeOutside:h,extrapolationValue:l,keepAspectRatioPolicy:M(m),mode:M(p),nearestMode:M(r)})},841176:(a,b,c,d,f,g,h)=>{e.jb("Slice",a,{starts:b?Array.from(H().subarray(Number(b)>>>0,Number(c)>>>0)):[],ends:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[],axes:g?Array.from(H().subarray(Number(g)>>>0,Number(h)>>>0)):[]})},841440:a=>{e.jb("Tile",a,void 0)},841492:(a,b,c)=>{e.jb("InstanceNormalization",a,{epsilon:b,format:c?"NHWC": +"NCHW"})},841606:(a,b,c)=>{e.jb("InstanceNormalization",a,{epsilon:b,format:c?"NHWC":"NCHW"})},841720:a=>{e.jb("Range",a,void 0)},841773:(a,b)=>{e.jb("Einsum",a,{equation:M(b)})},841854:(a,b,c,d,f)=>{e.jb("Pad",a,{mode:b,value:c,pads:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},841997:(a,b,c,d,f,g)=>{e.jb("BatchNormalization",a,{epsilon:b,momentum:c,spatial:!!f,trainingMode:!!d,format:g?"NHWC":"NCHW"})},842166:(a,b,c,d,f,g)=>{e.jb("BatchNormalization",a,{epsilon:b,momentum:c,spatial:!!f, +trainingMode:!!d,format:g?"NHWC":"NCHW"})},842335:(a,b,c)=>{e.jb("CumSum",a,{exclusive:Number(b),reverse:Number(c)})},842432:(a,b,c)=>{e.jb("DequantizeLinear",a,{axis:b,blockSize:c})},842522:(a,b,c,d,f)=>{e.jb("GridSample",a,{align_corners:b,mode:M(c),padding_mode:M(d),format:f?"NHWC":"NCHW"})},842692:(a,b,c,d,f)=>{e.jb("GridSample",a,{align_corners:b,mode:M(c),padding_mode:M(d),format:f?"NHWC":"NCHW"})},842862:(a,b)=>{e.jb("ScatterND",a,{reduction:M(b)})},842947:(a,b,c,d,f,g,h,l,m)=>{e.jb("Attention", +a,{numHeads:b,isUnidirectional:c,maskFilterValue:d,scale:f,doRotary:g,qkvHiddenSizes:h?Array.from(H().subarray(Number(l)>>>0,Number(l)+h>>>0)):[],pastPresentShareBuffer:!!m})},843219:a=>{e.jb("BiasAdd",a,void 0)},843274:a=>{e.jb("BiasSplitGelu",a,void 0)},843335:a=>{e.jb("FastGelu",a,void 0)},843391:(a,b,c,d,f,g,h,l,m,p,r,u,w,y,ba,Vd)=>{e.jb("Conv",a,{format:u?"NHWC":"NCHW",auto_pad:b,dilations:c?Array.from(H().subarray(Number(c)>>>0,Number(d)>>>0)):[],group:f,kernel_shape:g?Array.from(H().subarray(Number(g)>>> +0,Number(h)>>>0)):[],pads:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],strides:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],w_is_const:()=>!!D()[Number(w)>>>0],activation:M(y),activation_params:ba?Array.from(Ga().subarray(Number(ba)>>>0,Number(Vd)>>>0)):[]})},843975:a=>{e.jb("Gelu",a,void 0)},844027:(a,b,c,d,f,g,h,l,m)=>{e.jb("GroupQueryAttention",a,{numHeads:b,kvNumHeads:c,scale:d,softcap:f,doRotary:g,rotaryInterleaved:h,smoothSoftmax:l,localWindowSize:m})},844244:(a, +b,c,d)=>{e.jb("LayerNormalization",a,{axis:b,epsilon:c,simplified:!!d})},844355:(a,b,c,d)=>{e.jb("LayerNormalization",a,{axis:b,epsilon:c,simplified:!!d})},844466:(a,b,c,d,f,g)=>{e.jb("MatMulNBits",a,{k:b,n:c,accuracyLevel:d,bits:f,blockSize:g})},844593:(a,b,c,d,f,g)=>{e.jb("MultiHeadAttention",a,{numHeads:b,isUnidirectional:c,maskFilterValue:d,scale:f,doRotary:g})},844752:(a,b)=>{e.jb("QuickGelu",a,{alpha:b})},844816:(a,b,c,d,f)=>{e.jb("RotaryEmbedding",a,{interleaved:!!b,numHeads:c,rotaryEmbeddingDim:d, +scale:f})},844955:(a,b,c)=>{e.jb("SkipLayerNormalization",a,{epsilon:b,simplified:!!c})},845057:(a,b,c)=>{e.jb("SkipLayerNormalization",a,{epsilon:b,simplified:!!c})},845159:(a,b,c,d)=>{e.jb("GatherBlockQuantized",a,{gatherAxis:b,quantizeAxis:c,blockSize:d})},845280:a=>{e.Zb(a)},845314:(a,b)=>e.ac(Number(a),Number(b),e.Fb.dc,e.Fb.errors)};function db(a,b,c){return Ec(async()=>{await e.Xb(Number(a),Number(b),Number(c))})}function cb(){return"undefined"!==typeof wasmOffsetConverter} +class Fc{name="ExitStatus";constructor(a){this.message=`Program terminated with exit(${a})`;this.status=a}} +var Gc=a=>{a.terminate();a.onmessage=()=>{}},Hc=[],Lc=a=>{0==N.length&&(Ic(),Jc(N[0]));var b=N.pop();if(!b)return 6;Kc.push(b);O[a.Ab]=b;b.Ab=a.Ab;var c={Bb:"run",fc:a.ec,Hb:a.Hb,Ab:a.Ab};n&&b.unref();b.postMessage(c,a.Mb);return 0},P=0,Q=(a,b,...c)=>{for(var d=2*c.length,f=Mc(),g=Nc(8*d),h=g>>>3,l=0;l>>0]=m)}a=Oc(a,0,d,g,b);Pc(f);return a}; +function Cc(a){if(q)return Q(0,1,a);wa=a;if(!(0{wa=a;if(q)throw Qc(a),"unwind";Cc(a)},N=[],Kc=[],Rc=[],O={};function Sc(){for(var a=e.numThreads-1;a--;)Ic();Hc.unshift(()=>{Ua++;Tc(()=>Wa())})}var Vc=a=>{var b=a.Ab;delete O[b];N.push(a);Kc.splice(Kc.indexOf(a),1);a.Ab=0;Uc(b)};function Na(){Rc.forEach(a=>a())} +var Jc=a=>new Promise(b=>{a.onmessage=g=>{g=g.data;var h=g.Bb;if(g.Gb&&g.Gb!=Ka()){var l=O[g.Gb];l?l.postMessage(g,g.Mb):x(`Internal error! Worker sent a message "${h}" to target pthread ${g.Gb}, but that thread no longer exists!`)}else if("checkMailbox"===h)Ra();else if("spawnThread"===h)Lc(g);else if("cleanupThread"===h)Vc(O[g.hc]);else if("loaded"===h)a.loaded=!0,n&&!a.Ab&&a.unref(),b(a);else if("alert"===h)alert(`Thread ${g.ic}: ${g.text}`);else if("setimmediate"===g.target)a.postMessage(g);else if("callHandler"=== +h)e[g.Qb](...g.args);else h&&x(`worker sent an unknown command ${h}`)};a.onerror=g=>{x(`${"worker sent an error!"} ${g.filename}:${g.lineno}: ${g.message}`);throw g;};n&&(a.on("message",g=>a.onmessage({data:g})),a.on("error",g=>a.onerror(g)));var c=[],d=[],f;for(f of d)e.propertyIsEnumerable(f)&&c.push(f);a.postMessage({Bb:"load",Rb:c,kc:z,lc:va})});function Tc(a){q?a():Promise.all(N.map(Jc)).then(a)} +function Ic(){var a=new Worker(new URL(import.meta.url),{type:"module",workerData:"em-pthread",name:"em-pthread"});N.push(a)}var La=a=>{E();var b=I()[a+52>>>2>>>0];a=I()[a+56>>>2>>>0];Wc(b,b-a);Pc(b)},Qa=(a,b)=>{P=0;a=Xc(a,b);0>>=0;var d=new Zc(a);b>>>=0;c>>>=0;I()[d.Ib+16>>>2>>>0]=0;I()[d.Ib+4>>>2>>>0]=b;I()[d.Ib+8>>>2>>>0]=c;$c=a;ad++;throw $c;} +function bd(a,b,c,d){return q?Q(2,1,a,b,c,d):fb(a,b,c,d)}function fb(a,b,c,d){a>>>=0;b>>>=0;c>>>=0;d>>>=0;if("undefined"==typeof SharedArrayBuffer)return 6;var f=[];if(q&&0===f.length)return bd(a,b,c,d);a={ec:c,Ab:a,Hb:d,Mb:f};return q?(a.Bb="spawnThread",postMessage(a,f),0):Lc(a)} +var cd="undefined"!=typeof TextDecoder?new TextDecoder:void 0,dd=(a,b=0,c=NaN)=>{b>>>=0;var d=b+c;for(c=b;a[c]&&!(c>=d);)++c;if(16f?d+=String.fromCharCode(f):(f-=65536,d+=String.fromCharCode(55296|f>>10,56320| +f&1023))}}else d+=String.fromCharCode(f)}return d},M=(a,b)=>(a>>>=0)?dd(F(),a,b):"";function gb(a,b,c){return q?Q(3,1,a,b,c):0}function hb(a,b){if(q)return Q(4,1,a,b)} +var ed=a=>{for(var b=0,c=0;c=d?b++:2047>=d?b+=2:55296<=d&&57343>=d?(b+=4,++c):b+=3}return b},fd=(a,b,c)=>{var d=F();b>>>=0;if(0=h){var l=a.charCodeAt(++g);h=65536+((h&1023)<<10)|l&1023}if(127>=h){if(b>=c)break;d[b++>>>0]=h}else{if(2047>=h){if(b+1>=c)break;d[b++>>>0]=192|h>>6}else{if(65535>=h){if(b+2>=c)break;d[b++>>>0]=224|h>>12}else{if(b+3>=c)break;d[b++>>>0]=240|h>>18; +d[b++>>>0]=128|h>>12&63}d[b++>>>0]=128|h>>6&63}d[b++>>>0]=128|h&63}}d[b>>>0]=0;a=b-f}else a=0;return a};function ib(a,b){if(q)return Q(5,1,a,b)}function jb(a,b,c){if(q)return Q(6,1,a,b,c)}function kb(a,b,c){return q?Q(7,1,a,b,c):0}function lb(a,b){if(q)return Q(8,1,a,b)}function mb(a,b,c){if(q)return Q(9,1,a,b,c)}function nb(a,b,c,d){if(q)return Q(10,1,a,b,c,d)}function ob(a,b,c,d){if(q)return Q(11,1,a,b,c,d)}function pb(a,b,c,d){if(q)return Q(12,1,a,b,c,d)}function qb(a){if(q)return Q(13,1,a)} +function rb(a,b){if(q)return Q(14,1,a,b)}function sb(a,b,c){if(q)return Q(15,1,a,b,c)}var tb=()=>L(""),gd,R=a=>{for(var b="";F()[a>>>0];)b+=gd[F()[a++>>>0]];return b},hd={},jd={},kd={},S;function ld(a,b,c={}){var d=b.name;if(!a)throw new S(`type "${d}" must have a positive integer typeid pointer`);if(jd.hasOwnProperty(a)){if(c.Sb)return;throw new S(`Cannot register type '${d}' twice`);}jd[a]=b;delete kd[a];hd.hasOwnProperty(a)&&(b=hd[a],delete hd[a],b.forEach(f=>f()))} +function T(a,b,c={}){return ld(a,b,c)}var md=(a,b,c)=>{switch(b){case 1:return c?d=>D()[d>>>0]:d=>F()[d>>>0];case 2:return c?d=>G()[d>>>1>>>0]:d=>Fa()[d>>>1>>>0];case 4:return c?d=>H()[d>>>2>>>0]:d=>I()[d>>>2>>>0];case 8:return c?d=>C[d>>>3]:d=>Da[d>>>3];default:throw new TypeError(`invalid integer width (${b}): ${a}`);}}; +function ub(a,b,c){a>>>=0;c>>>=0;b=R(b>>>0);T(a,{name:b,fromWireType:d=>d,toWireType:function(d,f){if("bigint"!=typeof f&&"number"!=typeof f)throw null===f?f="null":(d=typeof f,f="object"===d||"array"===d||"function"===d?f.toString():""+f),new TypeError(`Cannot convert "${f}" to ${this.name}`);"number"==typeof f&&(f=BigInt(f));return f},Cb:U,readValueFromPointer:md(b,c,-1==b.indexOf("u")),Db:null})}var U=8; +function vb(a,b,c,d){a>>>=0;b=R(b>>>0);T(a,{name:b,fromWireType:function(f){return!!f},toWireType:function(f,g){return g?c:d},Cb:U,readValueFromPointer:function(f){return this.fromWireType(F()[f>>>0])},Db:null})}var nd=[],V=[];function Ob(a){a>>>=0;9{if(!a)throw new S("Cannot use deleted val. handle = "+a);return V[a]},X=a=>{switch(a){case void 0:return 2;case null:return 4;case !0:return 6;case !1:return 8;default:const b=nd.pop()||V.length;V[b]=a;V[b+1]=1;return b}};function od(a){return this.fromWireType(I()[a>>>2>>>0])}var pd={name:"emscripten::val",fromWireType:a=>{var b=W(a);Ob(a);return b},toWireType:(a,b)=>X(b),Cb:U,readValueFromPointer:od,Db:null};function wb(a){return T(a>>>0,pd)} +var qd=(a,b)=>{switch(b){case 4:return function(c){return this.fromWireType(Ga()[c>>>2>>>0])};case 8:return function(c){return this.fromWireType(J()[c>>>3>>>0])};default:throw new TypeError(`invalid float width (${b}): ${a}`);}};function xb(a,b,c){a>>>=0;c>>>=0;b=R(b>>>0);T(a,{name:b,fromWireType:d=>d,toWireType:(d,f)=>f,Cb:U,readValueFromPointer:qd(b,c),Db:null})} +function yb(a,b,c,d,f){a>>>=0;c>>>=0;b=R(b>>>0);-1===f&&(f=4294967295);f=l=>l;if(0===d){var g=32-8*c;f=l=>l<>>g}var h=b.includes("unsigned")?function(l,m){return m>>>0}:function(l,m){return m};T(a,{name:b,fromWireType:f,toWireType:h,Cb:U,readValueFromPointer:md(b,c,0!==d),Db:null})} +function zb(a,b,c){function d(g){var h=I()[g>>>2>>>0];g=I()[g+4>>>2>>>0];return new f(D().buffer,g,h)}a>>>=0;var f=[Int8Array,Uint8Array,Int16Array,Uint16Array,Int32Array,Uint32Array,Float32Array,Float64Array,BigInt64Array,BigUint64Array][b];c=R(c>>>0);T(a,{name:c,fromWireType:d,Cb:U,readValueFromPointer:d},{Sb:!0})} +function Ab(a,b){a>>>=0;b=R(b>>>0);T(a,{name:b,fromWireType:function(c){for(var d=I()[c>>>2>>>0],f=c+4,g,h=f,l=0;l<=d;++l){var m=f+l;if(l==d||0==F()[m>>>0])h=M(h,m-h),void 0===g?g=h:(g+=String.fromCharCode(0),g+=h),h=m+1}Y(c);return g},toWireType:function(c,d){d instanceof ArrayBuffer&&(d=new Uint8Array(d));var f="string"==typeof d;if(!(f||d instanceof Uint8Array||d instanceof Uint8ClampedArray||d instanceof Int8Array))throw new S("Cannot pass non-string to std::string");var g=f?ed(d):d.length;var h= +rd(4+g+1),l=h+4;I()[h>>>2>>>0]=g;if(f)fd(d,l,g+1);else if(f)for(f=0;f>>0]=m}else for(f=0;f>>0]=d[f];null!==c&&c.push(Y,h);return h},Cb:U,readValueFromPointer:od,Db(c){Y(c)}})} +var sd="undefined"!=typeof TextDecoder?new TextDecoder("utf-16le"):void 0,td=(a,b)=>{var c=a>>1;for(var d=c+b/2;!(c>=d)&&Fa()[c>>>0];)++c;c<<=1;if(32=b/2);++d){var f=G()[a+2*d>>>1>>>0];if(0==f)break;c+=String.fromCharCode(f)}return c},ud=(a,b,c)=>{c??=2147483647;if(2>c)return 0;c-=2;var d=b;c=c<2*a.length?c/2:a.length;for(var f=0;f>>1>>>0]=g;b+=2}G()[b>>>1>>>0]=0;return b-d},vd=a=>2*a.length,wd=(a,b)=>{for(var c= +0,d="";!(c>=b/4);){var f=H()[a+4*c>>>2>>>0];if(0==f)break;++c;65536<=f?(f-=65536,d+=String.fromCharCode(55296|f>>10,56320|f&1023)):d+=String.fromCharCode(f)}return d},xd=(a,b,c)=>{b>>>=0;c??=2147483647;if(4>c)return 0;var d=b;c=d+c-4;for(var f=0;f=g){var h=a.charCodeAt(++f);g=65536+((g&1023)<<10)|h&1023}H()[b>>>2>>>0]=g;b+=4;if(b+4>c)break}H()[b>>>2>>>0]=0;return b-d},yd=a=>{for(var b=0,c=0;c= +d&&++c;b+=4}return b}; +function Bb(a,b,c){a>>>=0;b>>>=0;c>>>=0;c=R(c);if(2===b){var d=td;var f=ud;var g=vd;var h=l=>Fa()[l>>>1>>>0]}else 4===b&&(d=wd,f=xd,g=yd,h=l=>I()[l>>>2>>>0]);T(a,{name:c,fromWireType:l=>{for(var m=I()[l>>>2>>>0],p,r=l+4,u=0;u<=m;++u){var w=l+4+u*b;if(u==m||0==h(w))r=d(r,w-r),void 0===p?p=r:(p+=String.fromCharCode(0),p+=r),r=w+b}Y(l);return p},toWireType:(l,m)=>{if("string"!=typeof m)throw new S(`Cannot pass non-string to C++ string type ${c}`);var p=g(m),r=rd(4+p+b);I()[r>>>2>>>0]=p/b;f(m,r+4,p+b); +null!==l&&l.push(Y,r);return r},Cb:U,readValueFromPointer:od,Db(l){Y(l)}})}function Cb(a,b){a>>>=0;b=R(b>>>0);T(a,{Tb:!0,name:b,Cb:0,fromWireType:()=>{},toWireType:()=>{}})}function Db(a){Ma(a>>>0,!k,1,!ea,131072,!1);Na()}var zd=a=>{if(!A)try{if(a(),!(0>>=0;"function"===typeof Atomics.jc&&(Atomics.jc(H(),a>>>2,a).value.then(Ra),a+=128,Atomics.store(H(),a>>>2,1))}var Ra=()=>{var a=Ka();a&&(Oa(a),zd(Ad))};function Eb(a,b){a>>>=0;a==b>>>0?setTimeout(Ra):q?postMessage({Gb:a,Bb:"checkMailbox"}):(a=O[a])&&a.postMessage({Bb:"checkMailbox"})}var Bd=[];function Fb(a,b,c,d,f){b>>>=0;d/=2;Bd.length=d;c=f>>>0>>>3;for(f=0;f>>0];return(b?Dc[b]:Cd[a])(...Bd)}var Gb=()=>{P=0}; +function Hb(a){a>>>=0;q?postMessage({Bb:"cleanupThread",hc:a}):Vc(O[a])}function Ib(a){n&&O[a>>>0].ref()}var Ed=(a,b)=>{var c=jd[a];if(void 0===c)throw a=Dd(a),c=R(a),Y(a),new S(`${b} has unknown type ${c}`);return c},Fd=(a,b,c)=>{var d=[];a=a.toWireType(d,c);d.length&&(I()[b>>>2>>>0]=X(d));return a};function Jb(a,b,c){b>>>=0;c>>>=0;a=W(a>>>0);b=Ed(b,"emval::as");return Fd(b,c,a)}function Kb(a,b){b>>>=0;a=W(a>>>0);b=Ed(b,"emval::as");return b.toWireType(null,a)}var Gd=a=>{try{a()}catch(b){L(b)}}; +function Hd(){var a=K,b={};for(let [c,d]of Object.entries(a))b[c]="function"==typeof d?(...f)=>{Id.push(c);try{return d(...f)}finally{A||(Id.pop(),t&&1===Z&&0===Id.length&&(Z=0,P+=1,Gd(Jd),"undefined"!=typeof Fibers&&Fibers.rc()))}}:d;return b}var Z=0,t=null,Kd=0,Id=[],Ld={},Md={},Nd=0,Od=null,Pd=[];function ia(){return new Promise((a,b)=>{Od={resolve:a,reject:b}})} +function Qd(){var a=rd(65548),b=a+12;I()[a>>>2>>>0]=b;I()[a+4>>>2>>>0]=b+65536;b=Id[0];var c=Ld[b];void 0===c&&(c=Nd++,Ld[b]=c,Md[c]=b);b=c;H()[a+8>>>2>>>0]=b;return a}function Rd(){var a=H()[t+8>>>2>>>0];a=K[Md[a]];--P;return a()} +function Sd(a){if(!A){if(0===Z){var b=!1,c=!1;a((d=0)=>{if(!A&&(Kd=d,b=!0,c)){Z=2;Gd(()=>Td(t));"undefined"!=typeof MainLoop&&MainLoop.Pb&&MainLoop.resume();d=!1;try{var f=Rd()}catch(l){f=l,d=!0}var g=!1;if(!t){var h=Od;h&&(Od=null,(d?h.reject:h.resolve)(f),g=!0)}if(d&&!g)throw f;}});c=!0;b||(Z=1,t=Qd(),"undefined"!=typeof MainLoop&&MainLoop.Pb&&MainLoop.pause(),Gd(()=>Ud(t)))}else 2===Z?(Z=0,Gd(Wd),Y(t),t=null,Pd.forEach(zd)):L(`invalid state: ${Z}`);return Kd}} +function Ec(a){return Sd(b=>{a().then(b)})}function Lb(a){a>>>=0;return Ec(async()=>{var b=await W(a);return X(b)})}var Xd=[];function Mb(a,b,c,d){c>>>=0;d>>>=0;a=Xd[a>>>0];b=W(b>>>0);return a(null,b,c,d)}var Yd={},Zd=a=>{var b=Yd[a];return void 0===b?R(a):b};function Nb(a,b,c,d,f){c>>>=0;d>>>=0;f>>>=0;a=Xd[a>>>0];b=W(b>>>0);c=Zd(c);return a(b,b[c],d,f)}var $d=()=>"object"==typeof globalThis?globalThis:Function("return this")(); +function Pb(a){a>>>=0;if(0===a)return X($d());a=Zd(a);return X($d()[a])}var ae=a=>{var b=Xd.length;Xd.push(a);return b},be=(a,b)=>{for(var c=Array(a),d=0;d>>2>>>0],"parameter "+d);return c},ce=(a,b)=>Object.defineProperty(b,"name",{value:a}); +function de(a){var b=Function;if(!(b instanceof Function))throw new TypeError(`new_ called with constructor type ${typeof b} which is not a function`);var c=ce(b.name||"unknownFunctionName",function(){});c.prototype=b.prototype;c=new c;a=b.apply(c,a);return a instanceof Object?a:c} +function Qb(a,b,c){b=be(a,b>>>0);var d=b.shift();a--;var f="return function (obj, func, destructorsRef, args) {\n",g=0,h=[];0===c&&h.push("obj");for(var l=["retType"],m=[d],p=0;pr.name).join(", ")}) => ${d.name}>`;return ae(ce(c,a))}function Rb(a){a=Zd(a>>>0);return X(e[a])}function Sb(a,b){b>>>=0;a=W(a>>>0);b=W(b);return X(a[b])}function Tb(a){a>>>=0;9>>0);for(var b=Array(a.length),c=0;c>>0))}function Xb(){return X({})} +function Yb(a){a>>>=0;for(var b=W(a);b.length;){var c=b.pop();b.pop()(c)}Ob(a)}function Zb(a,b,c){b>>>=0;c>>>=0;a=W(a>>>0);b=W(b);c=W(c);a[b]=c}function $b(a,b){b>>>=0;a=Ed(a>>>0,"_emval_take_value");a=a.readValueFromPointer(b);return X(a)} +function ac(a,b){a=-9007199254740992>a||9007199254740992>>=0;a=new Date(1E3*a);H()[b>>>2>>>0]=a.getUTCSeconds();H()[b+4>>>2>>>0]=a.getUTCMinutes();H()[b+8>>>2>>>0]=a.getUTCHours();H()[b+12>>>2>>>0]=a.getUTCDate();H()[b+16>>>2>>>0]=a.getUTCMonth();H()[b+20>>>2>>>0]=a.getUTCFullYear()-1900;H()[b+24>>>2>>>0]=a.getUTCDay();a=(a.getTime()-Date.UTC(a.getUTCFullYear(),0,1,0,0,0,0))/864E5|0;H()[b+28>>>2>>>0]=a} +var ee=a=>0===a%4&&(0!==a%100||0===a%400),fe=[0,31,60,91,121,152,182,213,244,274,305,335],ge=[0,31,59,90,120,151,181,212,243,273,304,334]; +function bc(a,b){a=-9007199254740992>a||9007199254740992>>=0;a=new Date(1E3*a);H()[b>>>2>>>0]=a.getSeconds();H()[b+4>>>2>>>0]=a.getMinutes();H()[b+8>>>2>>>0]=a.getHours();H()[b+12>>>2>>>0]=a.getDate();H()[b+16>>>2>>>0]=a.getMonth();H()[b+20>>>2>>>0]=a.getFullYear()-1900;H()[b+24>>>2>>>0]=a.getDay();var c=(ee(a.getFullYear())?fe:ge)[a.getMonth()]+a.getDate()-1|0;H()[b+28>>>2>>>0]=c;H()[b+36>>>2>>>0]=-(60*a.getTimezoneOffset());c=(new Date(a.getFullYear(),6,1)).getTimezoneOffset(); +var d=(new Date(a.getFullYear(),0,1)).getTimezoneOffset();a=(c!=d&&a.getTimezoneOffset()==Math.min(d,c))|0;H()[b+32>>>2>>>0]=a} +function cc(a){a>>>=0;var b=new Date(H()[a+20>>>2>>>0]+1900,H()[a+16>>>2>>>0],H()[a+12>>>2>>>0],H()[a+8>>>2>>>0],H()[a+4>>>2>>>0],H()[a>>>2>>>0],0),c=H()[a+32>>>2>>>0],d=b.getTimezoneOffset(),f=(new Date(b.getFullYear(),6,1)).getTimezoneOffset(),g=(new Date(b.getFullYear(),0,1)).getTimezoneOffset(),h=Math.min(g,f);0>c?H()[a+32>>>2>>>0]=Number(f!=g&&h==d):0>>2>>>0]=b.getDay();c=(ee(b.getFullYear())?fe:ge)[b.getMonth()]+ +b.getDate()-1|0;H()[a+28>>>2>>>0]=c;H()[a>>>2>>>0]=b.getSeconds();H()[a+4>>>2>>>0]=b.getMinutes();H()[a+8>>>2>>>0]=b.getHours();H()[a+12>>>2>>>0]=b.getDate();H()[a+16>>>2>>>0]=b.getMonth();H()[a+20>>>2>>>0]=b.getYear();a=b.getTime();return BigInt(isNaN(a)?-1:a/1E3)}function dc(a,b,c,d,f,g,h){return q?Q(16,1,a,b,c,d,f,g,h):-52}function ec(a,b,c,d,f,g){if(q)return Q(17,1,a,b,c,d,f,g)}var he={},pc=()=>performance.timeOrigin+performance.now(); +function fc(a,b){if(q)return Q(18,1,a,b);he[a]&&(clearTimeout(he[a].id),delete he[a]);if(!b)return 0;var c=setTimeout(()=>{delete he[a];zd(()=>ie(a,performance.timeOrigin+performance.now()))},b);he[a]={id:c,qc:b};return 0} +function gc(a,b,c,d){a>>>=0;b>>>=0;c>>>=0;d>>>=0;var f=(new Date).getFullYear(),g=(new Date(f,0,1)).getTimezoneOffset();f=(new Date(f,6,1)).getTimezoneOffset();var h=Math.max(g,f);I()[a>>>2>>>0]=60*h;H()[b>>>2>>>0]=Number(g!=f);b=l=>{var m=Math.abs(l);return`UTC${0<=l?"-":"+"}${String(Math.floor(m/60)).padStart(2,"0")}${String(m%60).padStart(2,"0")}`};a=b(g);b=b(f);fDate.now(),je=1; +function hc(a,b,c){if(!(0<=a&&3>=a))return 28;if(0===a)a=Date.now();else if(je)a=performance.timeOrigin+performance.now();else return 52;C[c>>>0>>>3]=BigInt(Math.round(1E6*a));return 0}var ke=[],le=(a,b)=>{ke.length=0;for(var c;c=F()[a++>>>0];){var d=105!=c;d&=112!=c;b+=d&&b%8?4:0;ke.push(112==c?I()[b>>>2>>>0]:106==c?C[b>>>3]:105==c?H()[b>>>2>>>0]:J()[b>>>3>>>0]);b+=d?8:4}return ke};function ic(a,b,c){a>>>=0;b=le(b>>>0,c>>>0);return Dc[a](...b)} +function jc(a,b,c){a>>>=0;b=le(b>>>0,c>>>0);return Dc[a](...b)}var kc=()=>{};function mc(a,b){return x(M(a>>>0,b>>>0))}var nc=()=>{P+=1;throw"unwind";};function oc(){return 4294901760}var qc=()=>n?require("os").cpus().length:navigator.hardwareConcurrency;function rc(){L("Cannot use emscripten_pc_get_function without -sUSE_OFFSET_CONVERTER");return 0} +function sc(a){a>>>=0;var b=F().length;if(a<=b||4294901760=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,a+100663296);a:{d=(Math.min(4294901760,65536*Math.ceil(Math.max(a,d)/65536))-z.buffer.byteLength+65535)/65536|0;try{z.grow(d);E();var f=1;break a}catch(g){}f=void 0}if(f)return!0}return!1}var me=()=>{L("Cannot use convertFrameToPC (needed by __builtin_return_address) without -sUSE_OFFSET_CONVERTER");return 0},ne={},oe=a=>{a.forEach(b=>{var c=me();c&&(ne[c]=b)})}; +function tc(){var a=Error().stack.toString().split("\n");"Error"==a[0]&&a.shift();oe(a);ne.Lb=me();ne.cc=a;return ne.Lb}function uc(a,b,c){a>>>=0;b>>>=0;if(ne.Lb==a)var d=ne.cc;else d=Error().stack.toString().split("\n"),"Error"==d[0]&&d.shift(),oe(d);for(var f=3;d[f]&&me()!=a;)++f;for(a=0;a>>2>>>0]=me();return a} +var pe={},re=()=>{if(!qe){var a={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:("object"==typeof navigator&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8",_:la||"./this.program"},b;for(b in pe)void 0===pe[b]?delete a[b]:a[b]=pe[b];var c=[];for(b in a)c.push(`${b}=${a[b]}`);qe=c}return qe},qe; +function vc(a,b){if(q)return Q(19,1,a,b);a>>>=0;b>>>=0;var c=0;re().forEach((d,f)=>{var g=b+c;f=I()[a+4*f>>>2>>>0]=g;for(g=0;g>>0]=d.charCodeAt(g);D()[f>>>0]=0;c+=d.length+1});return 0}function wc(a,b){if(q)return Q(20,1,a,b);a>>>=0;b>>>=0;var c=re();I()[a>>>2>>>0]=c.length;var d=0;c.forEach(f=>d+=f.length+1);I()[b>>>2>>>0]=d;return 0}function yc(a){return q?Q(21,1,a):52}function zc(a,b,c,d){return q?Q(22,1,a,b,c,d):52}function Ac(a,b,c,d){return q?Q(23,1,a,b,c,d):70} +var se=[null,[],[]];function Bc(a,b,c,d){if(q)return Q(24,1,a,b,c,d);b>>>=0;c>>>=0;d>>>=0;for(var f=0,g=0;g>>2>>>0],l=I()[b+4>>>2>>>0];b+=8;for(var m=0;m>>0],r=se[a];0===p||10===p?((1===a?ta:x)(dd(r)),r.length=0):r.push(p)}f+=l}I()[d>>>2>>>0]=f;return 0}q||Sc();for(var te=Array(256),ue=0;256>ue;++ue)te[ue]=String.fromCharCode(ue);gd=te;S=e.BindingError=class extends Error{constructor(a){super(a);this.name="BindingError"}}; +e.InternalError=class extends Error{constructor(a){super(a);this.name="InternalError"}};V.push(0,1,void 0,1,null,1,!0,1,!1,1);e.count_emval_handles=()=>V.length/2-5-nd.length;var Cd=[Cc,Qc,bd,gb,hb,ib,jb,kb,lb,mb,nb,ob,pb,qb,rb,sb,dc,ec,fc,vc,wc,yc,zc,Ac,Bc],bb,K; +(async function(){function a(d,f){K=d.exports;K=Hd();K=ve();Rc.push(K.ib);va=f;Wa();return K}Ua++;var b=ab();if(e.instantiateWasm)return new Promise(d=>{e.instantiateWasm(b,(f,g)=>{a(f,g);d(f.exports)})});if(q)return new Promise(d=>{Ha=f=>{var g=new WebAssembly.Instance(f,ab());d(a(g,f))}});Xa??=e.locateFile?e.locateFile?e.locateFile("ort-wasm-simd-threaded.jsep.wasm",v):v+"ort-wasm-simd-threaded.jsep.wasm":(new URL("ort-wasm-simd-threaded.jsep.wasm",import.meta.url)).href;try{var c=await $a(b); +return a(c.instance,c.module)}catch(d){return ca(d),Promise.reject(d)}})();var Dd=a=>(Dd=K.Da)(a),Pa=()=>(Pa=K.Ea)();e._OrtInit=(a,b)=>(e._OrtInit=K.Fa)(a,b);e._OrtGetLastError=(a,b)=>(e._OrtGetLastError=K.Ga)(a,b);e._OrtCreateSessionOptions=(a,b,c,d,f,g,h,l,m,p)=>(e._OrtCreateSessionOptions=K.Ha)(a,b,c,d,f,g,h,l,m,p);e._OrtAppendExecutionProvider=(a,b,c,d,f)=>(e._OrtAppendExecutionProvider=K.Ia)(a,b,c,d,f);e._OrtAddFreeDimensionOverride=(a,b,c)=>(e._OrtAddFreeDimensionOverride=K.Ja)(a,b,c); +e._OrtAddSessionConfigEntry=(a,b,c)=>(e._OrtAddSessionConfigEntry=K.Ka)(a,b,c);e._OrtReleaseSessionOptions=a=>(e._OrtReleaseSessionOptions=K.La)(a);e._OrtCreateSession=(a,b,c)=>(e._OrtCreateSession=K.Ma)(a,b,c);e._OrtReleaseSession=a=>(e._OrtReleaseSession=K.Na)(a);e._OrtGetInputOutputCount=(a,b,c)=>(e._OrtGetInputOutputCount=K.Oa)(a,b,c);e._OrtGetInputOutputMetadata=(a,b,c,d)=>(e._OrtGetInputOutputMetadata=K.Pa)(a,b,c,d);e._OrtFree=a=>(e._OrtFree=K.Qa)(a); +e._OrtCreateTensor=(a,b,c,d,f,g)=>(e._OrtCreateTensor=K.Ra)(a,b,c,d,f,g);e._OrtGetTensorData=(a,b,c,d,f)=>(e._OrtGetTensorData=K.Sa)(a,b,c,d,f);e._OrtReleaseTensor=a=>(e._OrtReleaseTensor=K.Ta)(a);e._OrtCreateRunOptions=(a,b,c,d)=>(e._OrtCreateRunOptions=K.Ua)(a,b,c,d);e._OrtAddRunConfigEntry=(a,b,c)=>(e._OrtAddRunConfigEntry=K.Va)(a,b,c);e._OrtReleaseRunOptions=a=>(e._OrtReleaseRunOptions=K.Wa)(a);e._OrtCreateBinding=a=>(e._OrtCreateBinding=K.Xa)(a); +e._OrtBindInput=(a,b,c)=>(e._OrtBindInput=K.Ya)(a,b,c);e._OrtBindOutput=(a,b,c,d)=>(e._OrtBindOutput=K.Za)(a,b,c,d);e._OrtClearBoundOutputs=a=>(e._OrtClearBoundOutputs=K._a)(a);e._OrtReleaseBinding=a=>(e._OrtReleaseBinding=K.$a)(a);e._OrtRunWithBinding=(a,b,c,d,f)=>(e._OrtRunWithBinding=K.ab)(a,b,c,d,f);e._OrtRun=(a,b,c,d,f,g,h,l)=>(e._OrtRun=K.bb)(a,b,c,d,f,g,h,l);e._OrtEndProfiling=a=>(e._OrtEndProfiling=K.cb)(a);e._JsepOutput=(a,b,c)=>(e._JsepOutput=K.db)(a,b,c); +e._JsepGetNodeName=a=>(e._JsepGetNodeName=K.eb)(a); +var Ka=()=>(Ka=K.fb)(),Y=e._free=a=>(Y=e._free=K.gb)(a),rd=e._malloc=a=>(rd=e._malloc=K.hb)(a),Ma=(a,b,c,d,f,g)=>(Ma=K.kb)(a,b,c,d,f,g),Sa=()=>(Sa=K.lb)(),Oc=(a,b,c,d,f)=>(Oc=K.mb)(a,b,c,d,f),Uc=a=>(Uc=K.nb)(a),Yc=a=>(Yc=K.ob)(a),ie=(a,b)=>(ie=K.pb)(a,b),Ad=()=>(Ad=K.qb)(),Wc=(a,b)=>(Wc=K.rb)(a,b),Pc=a=>(Pc=K.sb)(a),Nc=a=>(Nc=K.tb)(a),Mc=()=>(Mc=K.ub)(),Xc=e.dynCall_ii=(a,b)=>(Xc=e.dynCall_ii=K.vb)(a,b),Ud=a=>(Ud=K.wb)(a),Jd=()=>(Jd=K.xb)(),Td=a=>(Td=K.yb)(a),Wd=()=>(Wd=K.zb)(); +function ve(){var a=K;a=Object.assign({},a);var b=d=>f=>d(f)>>>0,c=d=>()=>d()>>>0;a.Da=b(a.Da);a.fb=c(a.fb);a.hb=b(a.hb);a.tb=b(a.tb);a.ub=c(a.ub);a.__cxa_get_exception_ptr=b(a.__cxa_get_exception_ptr);return a}e.stackSave=()=>Mc();e.stackRestore=a=>Pc(a);e.stackAlloc=a=>Nc(a); +e.setValue=function(a,b,c="i8"){c.endsWith("*")&&(c="*");switch(c){case "i1":D()[a>>>0]=b;break;case "i8":D()[a>>>0]=b;break;case "i16":G()[a>>>1>>>0]=b;break;case "i32":H()[a>>>2>>>0]=b;break;case "i64":C[a>>>3]=BigInt(b);break;case "float":Ga()[a>>>2>>>0]=b;break;case "double":J()[a>>>3>>>0]=b;break;case "*":I()[a>>>2>>>0]=b;break;default:L(`invalid type for setValue: ${c}`)}}; +e.getValue=function(a,b="i8"){b.endsWith("*")&&(b="*");switch(b){case "i1":return D()[a>>>0];case "i8":return D()[a>>>0];case "i16":return G()[a>>>1>>>0];case "i32":return H()[a>>>2>>>0];case "i64":return C[a>>>3];case "float":return Ga()[a>>>2>>>0];case "double":return J()[a>>>3>>>0];case "*":return I()[a>>>2>>>0];default:L(`invalid type for getValue: ${b}`)}};e.UTF8ToString=M;e.stringToUTF8=fd;e.lengthBytesUTF8=ed; +function we(){if(0 { 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/eventFingerprint.ts b/src/seqta/utils/calendarSync/eventFingerprint.ts new file mode 100644 index 00000000..bbede560 --- /dev/null +++ b/src/seqta/utils/calendarSync/eventFingerprint.ts @@ -0,0 +1,42 @@ +/** Stable content fingerprint for skip-unchanged calendar sync. */ + +export type FingerprintableEvent = { + summary: string; + location?: string; + description?: string; + startDateTime: string; + endDateTime: string; + timeZone: string; +}; + +export function eventFingerprint(event: FingerprintableEvent): string { + return [ + event.summary.trim(), + (event.location ?? "").trim(), + (event.description ?? "").trim(), + event.startDateTime.trim(), + event.endDateTime.trim(), + event.timeZone.trim(), + ].join("\n"); +} + +/** Parse `Key: {seqtaKey}` from Outlook event body text. */ +export function parseOutlookSeqtaKey(bodyContent: string | undefined | null): string | undefined { + if (!bodyContent) return undefined; + const match = bodyContent.match(/^Key:\s*(.+)$/m); + const key = match?.[1]?.trim(); + return key && key.length > 0 ? key : undefined; +} + +export function outlookDescriptionWithKey( + description: string | undefined, + seqtaKey: string, +): string { + const base = (description ?? "Synced by BetterSEQTA+").trim(); + const withoutKey = base + .split("\n") + .filter((line) => !/^Key:\s*/.test(line)) + .join("\n") + .trim(); + return `${withoutKey}\nKey: ${seqtaKey}`; +} diff --git a/src/seqta/utils/calendarSync/eventMap.ts b/src/seqta/utils/calendarSync/eventMap.ts new file mode 100644 index 00000000..617703b8 --- /dev/null +++ b/src/seqta/utils/calendarSync/eventMap.ts @@ -0,0 +1,70 @@ +import browser from "webextension-polyfill"; + +export interface EventMapEntry { + id: string; + date: string; + /** Last-synced content fingerprint; used to skip unchanged events. */ + fingerprint?: string; +} + +export type EventMapRecord = Record; + +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 ?? "", + fingerprint: value.fingerprint, + }; + } + return undefined; +} + +export function getStoredFingerprint( + value: string | EventMapEntry | undefined, +): string | undefined { + return normalizeEventMapEntry(value)?.fingerprint; +} + +export function getStoredEventId( + value: string | EventMapEntry | undefined, +): string | undefined { + 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 new file mode 100644 index 00000000..6206fa9e --- /dev/null +++ b/src/seqta/utils/calendarSync/lessonSyncShared.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it, jest } from "@jest/globals"; + +jest.mock("@/utils/verboseLog", () => ({ + verboseLog: jest.fn(), +})); + +import { + eventFingerprint, + outlookDescriptionWithKey, + parseOutlookSeqtaKey, +} from "./eventFingerprint"; +import { + buildLessonSyncResult, + collectOriginDeleteEntries, + entriesToPrune, + formatLessonSyncResultMessage, + mapPool, + mergeRemoteEventsIntoMap, + reportSyncProgress, + upsertLessonEvents, +} from "./lessonSyncShared"; +import type { GoogleCalendarSyncProgress } from "@/seqta/utils/googleCalendar/types"; +import { syncWindowRange } from "@/seqta/utils/googleCalendar/syncDateRange"; + +describe("reportSyncProgress", () => { + it("calls onProgress when provided", () => { + const onProgress = jest.fn(); + const progress: GoogleCalendarSyncProgress = { + phase: "upserting", + current: 1, + total: 5, + message: "Syncing events (1/5)…", + }; + + reportSyncProgress(onProgress, progress); + reportSyncProgress(undefined, progress); + + expect(onProgress).toHaveBeenCalledTimes(1); + expect(onProgress).toHaveBeenCalledWith(progress); + }); +}); + +describe("eventFingerprint", () => { + it("is stable for equivalent event content", () => { + const base = { + summary: "Math", + location: "MA1", + description: "Synced by BetterSEQTA+", + startDateTime: "2026-07-13T09:00:00", + endDateTime: "2026-07-13T10:00:00", + timeZone: "Australia/Adelaide", + }; + expect(eventFingerprint(base)).toBe(eventFingerprint({ ...base })); + expect(eventFingerprint(base)).not.toBe( + eventFingerprint({ ...base, summary: "English" }), + ); + }); +}); + +describe("outlook Key helpers", () => { + it("embeds and parses Key lines", () => { + const withKey = outlookDescriptionWithKey("Synced by BetterSEQTA+\nTeacher: A", "origin:cal:1"); + expect(withKey).toContain("Key: origin:cal:1"); + expect(parseOutlookSeqtaKey(withKey)).toBe("origin:cal:1"); + expect(outlookDescriptionWithKey(withKey, "origin:cal:2")).toContain("Key: origin:cal:2"); + expect(outlookDescriptionWithKey(withKey, "origin:cal:2").match(/^Key:/gm)).toHaveLength(1); + }); +}); + +describe("entriesToPrune", () => { + const origin = "https://school.seqta.com.au"; + const weeksAhead = 12; + const syncDate = syncWindowRange(weeksAhead).from; + const mapKey = `${origin}::${origin}:cal:1`; + const cancelledKey = `${origin}::${origin}:cal:cancelled`; + + it("returns nothing for incremental mode", () => { + expect( + entriesToPrune( + { [mapKey]: { id: "evt-1", date: syncDate } }, + origin, + "incremental", + weeksAhead, + new Set([mapKey]), + ), + ).toEqual([]); + }); + + it("prunes cancelled lessons still inside the sync window", () => { + const pruned = entriesToPrune( + { + [mapKey]: { id: "evt-1", date: syncDate }, + [cancelledKey]: { id: "evt-cancelled", date: syncDate }, + }, + origin, + "full", + weeksAhead, + new Set([mapKey]), + ); + expect(pruned).toEqual([[cancelledKey, "evt-cancelled"]]); + }); + + it("prunes events whose date is outside the sync window", () => { + const staleKey = `${origin}::${origin}:cal:old`; + const pruned = entriesToPrune( + { [staleKey]: { id: "evt-old", date: "2020-01-06" } }, + origin, + "full", + weeksAhead, + new Set(), + ); + expect(pruned).toEqual([[staleKey, "evt-old"]]); + }); +}); + +describe("mergeRemoteEventsIntoMap", () => { + const origin = "https://school.seqta.com.au"; + const mapKey = (o: string, seqtaKey: string) => `${o}::${seqtaKey}`; + + it("recovers remote IDs for the current origin only", () => { + const eventMap: Record = {}; + mergeRemoteEventsIntoMap( + eventMap, + origin, + [ + { + seqtaKey: `${origin}:cal:1`, + id: "remote-1", + date: "2026-07-13", + fingerprint: "fp-1", + }, + { + seqtaKey: "https://other.seqta.com.au:cal:2", + id: "remote-other", + date: "2026-07-13", + fingerprint: "fp-other", + }, + ], + mapKey, + ); + + expect(eventMap[`${origin}::${origin}:cal:1`]).toMatchObject({ + id: "remote-1", + fingerprint: "fp-1", + }); + expect(eventMap[`${origin}::https://other.seqta.com.au:cal:2`]).toBeUndefined(); + }); + + it("keeps an existing local fingerprint when reconciling", () => { + const key = `${origin}::${origin}:cal:1`; + const eventMap = { + [key]: { id: "old-id", date: "2026-07-13", fingerprint: "local-fp" }, + }; + mergeRemoteEventsIntoMap( + eventMap, + origin, + [ + { + seqtaKey: `${origin}:cal:1`, + id: "remote-1", + date: "2026-07-13", + fingerprint: "remote-fp", + }, + ], + mapKey, + ); + expect(eventMap[key]).toMatchObject({ id: "remote-1", fingerprint: "local-fp" }); + }); +}); + +describe("collectOriginDeleteEntries", () => { + const origin = "https://school.seqta.com.au"; + const mapKey = (o: string, seqtaKey: string) => `${o}::${seqtaKey}`; + + it("includes local map entries and matching remote events", () => { + const eventMap = { + [`${origin}::${origin}:cal:1`]: { id: "local-1", date: "2026-07-13" }, + }; + const entries = collectOriginDeleteEntries( + eventMap, + origin, + [ + { + seqtaKey: `${origin}:cal:2`, + id: "remote-2", + date: "2026-07-14", + fingerprint: "fp", + }, + { + seqtaKey: "", + id: "orphan-3", + date: "2026-07-15", + fingerprint: "fp", + }, + { + seqtaKey: "https://other.seqta.com.au:cal:9", + id: "other", + date: "2026-07-15", + fingerprint: "fp", + }, + ], + mapKey, + ); + + const ids = entries.map(([, id]) => id).sort(); + expect(ids).toEqual(["local-1", "orphan-3", "remote-2"]); + }); +}); + +describe("formatLessonSyncResultMessage", () => { + it("includes unchanged counts", () => { + expect( + formatLessonSyncResultMessage( + { success: true, created: 1, updated: 2, deleted: 0, skipped: 3 }, + "Google Calendar", + ), + ).toBe("Google Calendar updated (1 new, 2 updated, 3 unchanged)."); + }); + + it("reports up to date when nothing changed", () => { + expect( + formatLessonSyncResultMessage( + buildLessonSyncResult(0, 0, 0, 0, 0, Date.now()), + "Outlook Calendar", + ), + ).toBe("Outlook Calendar is up to date."); + }); +}); + +describe("upsertLessonEvents skip unchanged", () => { + it("skips API writes when fingerprint matches", async () => { + const origin = "https://school.seqta.com.au"; + const event = { + seqtaKey: `${origin}:cal:1`, + summary: "Math", + description: "Synced by BetterSEQTA+", + startDateTime: "2026-07-13T09:00:00", + endDateTime: "2026-07-13T10:00:00", + timeZone: "UTC", + }; + const fp = eventFingerprint(event); + const key = `${origin}::${event.seqtaKey}`; + const eventMap = { + [key]: { id: "existing", date: "2026-07-13", fingerprint: fp }, + }; + const upsert = jest.fn(async () => "existing"); + + const result = await upsertLessonEvents({ + events: [event], + eventMap, + origin, + staleEntryCount: 0, + totalSteps: 1, + lastSyncAt: Date.now(), + initialFailed: 0, + getAccessToken: async () => "token", + mapKey: (o, k) => `${o}::${k}`, + upsert, + writeState: async () => undefined, + logLabel: "Test", + }); + + expect(upsert).not.toHaveBeenCalled(); + expect(result).toMatchObject({ created: 0, updated: 0, skipped: 1, failed: 0 }); + }); + + it("runs upserts concurrently", async () => { + const origin = "https://school.seqta.com.au"; + let inFlight = 0; + let maxInFlight = 0; + const events = Array.from({ length: 6 }, (_, i) => ({ + seqtaKey: `${origin}:cal:${i}`, + summary: `Class ${i}`, + description: "Synced by BetterSEQTA+", + startDateTime: "2026-07-13T09:00:00", + endDateTime: "2026-07-13T10:00:00", + timeZone: "UTC", + })); + + const upsert = jest.fn(async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 30)); + inFlight -= 1; + return `id-${Math.random()}`; + }); + + const result = await upsertLessonEvents({ + events, + eventMap: {}, + origin, + staleEntryCount: 0, + totalSteps: events.length, + lastSyncAt: Date.now(), + initialFailed: 0, + getAccessToken: async () => "token", + mapKey: (o, k) => `${o}::${k}`, + upsert, + writeState: async () => undefined, + logLabel: "Test", + concurrency: 2, + }); + + expect(result.created).toBe(6); + expect(upsert).toHaveBeenCalledTimes(6); + expect(maxInFlight).toBeGreaterThan(1); + expect(maxInFlight).toBeLessThanOrEqual(2); + }); +}); + +describe("mapPool", () => { + it("limits concurrency", async () => { + let inFlight = 0; + let maxInFlight = 0; + await mapPool([1, 2, 3, 4, 5], 2, async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 20)); + inFlight -= 1; + }); + expect(maxInFlight).toBe(2); + }); +}); diff --git a/src/seqta/utils/calendarSync/lessonSyncShared.ts b/src/seqta/utils/calendarSync/lessonSyncShared.ts index 07e3b94f..74ffd537 100644 --- a/src/seqta/utils/calendarSync/lessonSyncShared.ts +++ b/src/seqta/utils/calendarSync/lessonSyncShared.ts @@ -1,14 +1,20 @@ import { verboseLog } from "@/utils/verboseLog"; import { getStoredEventId, + getStoredFingerprint, lessonDateFromSeqtaKey, normalizeEventMapEntry, -} from "@/seqta/utils/googleCalendar/eventMapEntry"; + type EventMapRecord, +} from "@/seqta/utils/calendarSync/eventMap"; +import { eventFingerprint } from "@/seqta/utils/calendarSync/eventFingerprint"; +import type { RemoteSyncedEvent } from "@/seqta/utils/calendarSync/remoteEvents"; import { isDateInRange, syncWindowRange, + type SyncDateRange, } from "@/seqta/utils/googleCalendar/syncDateRange"; import type { + GoogleCalendarDeleteResult, GoogleCalendarSyncOptions, GoogleCalendarSyncProgress, GoogleCalendarSyncResult, @@ -16,11 +22,42 @@ import type { export const EVENT_MAP_PERSIST_EVERY = 10; -export type EventMapRecord = Record; +/** Max concurrent Google/Outlook create/update/delete requests during sync. */ +export const SYNC_CONCURRENCY = 2; + +/** Same cap for deletes to avoid provider rate limits. */ +export const SYNC_DELETE_CONCURRENCY = 2; + +/** Run async work over items with a fixed concurrency pool. */ +export async function mapPool( + items: readonly T[], + concurrency: number, + worker: (item: T, index: number) => Promise, +): Promise { + if (items.length === 0) return; + const limit = Math.max(1, Math.min(concurrency, items.length)); + let nextIndex = 0; + + async function runWorker(): Promise { + while (true) { + const index = nextIndex; + nextIndex += 1; + if (index >= items.length) return; + await worker(items[index], index); + } + } + + await Promise.all(Array.from({ length: limit }, () => runWorker())); +} export type MappedLessonEvent = { seqtaKey: string; + summary: string; + location?: string; + description?: string; startDateTime: string; + endDateTime: string; + timeZone: string; }; export function reportSyncProgress( @@ -48,18 +85,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, @@ -67,6 +92,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]> = []; @@ -75,7 +102,9 @@ export function entriesToPrune( if (!mapKey.startsWith(prefix)) continue; const entry = normalizeEventMapEntry(raw); if (!entry) continue; - if (shouldPruneEntry(mode, entry, mapKey, window, currentMapKeys)) { + const missingFromTimetable = !currentMapKeys.has(mapKey); + const outsideWindow = entry.date ? !isDateInRange(entry.date, window) : false; + if (missingFromTimetable || outsideWindow) { entries.push([mapKey, entry.id]); } } @@ -83,6 +112,74 @@ export function entriesToPrune( return entries; } +/** Merge remote BS+ events into the local eventMap (origin-scoped keys). */ +export function mergeRemoteEventsIntoMap( + eventMap: EventMapRecord, + origin: string, + remoteEvents: RemoteSyncedEvent[], + mapKey: (origin: string, seqtaKey: string) => string, +): void { + for (const remote of remoteEvents) { + if (!remote.seqtaKey.startsWith(origin)) continue; + const key = mapKey(origin, remote.seqtaKey); + const existing = normalizeEventMapEntry(eventMap[key]); + eventMap[key] = { + id: remote.id, + date: remote.date || existing?.date || lessonDateFromSeqtaKey(remote.seqtaKey) || "", + // Prefer last-written local fingerprint so skip stays stable across API format quirks. + fingerprint: existing?.fingerprint ?? remote.fingerprint, + }; + } +} + +/** + * Build a deduped delete list from local map + remote events for an origin. + * Unkeyed remote events (empty seqtaKey) are included as orphan removals. + */ +export function collectOriginDeleteEntries( + eventMap: EventMapRecord, + origin: string, + remoteEvents: RemoteSyncedEvent[], + mapKey: (origin: string, seqtaKey: string) => string, +): Array<[string, string]> { + const byId = new Map(); + + for (const [key, id] of originEventMapEntries(eventMap, origin)) { + byId.set(id, key); + } + + for (const remote of remoteEvents) { + if (remote.seqtaKey && !remote.seqtaKey.startsWith(origin)) continue; + if (byId.has(remote.id)) continue; + const key = remote.seqtaKey + ? mapKey(origin, remote.seqtaKey) + : `${origin}::__orphan__:${remote.id}`; + byId.set(remote.id, key); + } + + return Array.from(byId.entries()).map(([id, key]) => [key, id]); +} + +export function clearOriginEventMapEntries(eventMap: EventMapRecord, origin: string): void { + const prefix = `${origin}::`; + for (const key of Object.keys(eventMap)) { + if (key.startsWith(prefix)) delete eventMap[key]; + } +} + +export function reconcileRangeForMode( + mode: "full" | "incremental", + weeksAhead: number, + ranges: { + syncWindowRange: (weeks: number) => SyncDateRange; + trailingWeekRange: (weeks: number) => SyncDateRange; + }, +): SyncDateRange { + return mode === "incremental" + ? ranges.trailingWeekRange(weeksAhead) + : ranges.syncWindowRange(weeksAhead); +} + export function notConfiguredSyncResult(error: string): GoogleCalendarSyncResult { return { success: false, configured: false, error }; } @@ -100,10 +197,116 @@ 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 skipped = result.skipped ?? 0; + const parts: string[] = []; + if (created > 0) parts.push(`${created} new`); + if (updated > 0) parts.push(`${updated} updated`); + if (deleted > 0) parts.push(`${deleted} removed`); + if (skipped > 0) parts.push(`${skipped} unchanged`); + if (parts.length === 0) return `${calendarLabel} is up to date.`; + return `${calendarLabel} updated (${parts.join(", ")}).`; +} + +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; + concurrency?: number; + }, +): Promise<{ deleted: number; failed: number }> { + if (entries.length === 0) return { deleted: 0, failed: 0 }; + + const { + persistProgress = false, + onProgress, + progressOffset = 0, + progressTotal = 0, + logLabel, + concurrency = SYNC_DELETE_CONCURRENCY, + } = options; + + let accessToken = await getAccessToken(); + const refreshAccessToken = async () => { + accessToken = await getAccessToken(); + return accessToken; + }; + let deleted = 0; + let failed = 0; + let completed = 0; + let persistChain: Promise = Promise.resolve(); + + await mapPool(entries, concurrency, async ([mapKey, eventId]) => { + try { + await deleteEvent(accessToken, eventId, refreshAccessToken); + delete eventMap[mapKey]; + deleted += 1; + } catch (err) { + verboseLog(`[BetterSEQTA+] ${logLabel} event delete failed:`, err); + failed += 1; + } + + completed += 1; + reportSyncProgress(onProgress, { + phase: "deleting", + current: progressOffset + completed, + total: progressTotal, + message: `Removing old events (${completed}/${entries.length})…`, + }); + + if (persistProgress && completed % EVENT_MAP_PERSIST_EVERY === 0) { + persistChain = persistChain.then(() => writeState({ eventMap })); + await persistChain; + } + }); + + if (persistProgress) { + await persistChain; + } + + return { deleted, failed }; +} + export function buildLessonSyncResult( created: number, updated: number, deleted: number, + skipped: number, failed: number, lastSyncAt: number, ): GoogleCalendarSyncResult { @@ -114,7 +317,7 @@ export function buildLessonSyncResult( created, updated, deleted, - skipped: 0, + skipped, failed, lastSyncAt, error: @@ -124,24 +327,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; @@ -165,11 +350,18 @@ type UpsertLessonEventsParams = { }) => Promise; onProgress?: GoogleCalendarSyncOptions["onProgress"]; logLabel: string; + concurrency?: number; }; export async function upsertLessonEvents( params: UpsertLessonEventsParams, -): Promise<{ created: number; updated: number; failed: number; accessToken: string }> { +): Promise<{ + created: number; + updated: number; + skipped: number; + failed: number; + accessToken: string; +}> { const { events, eventMap, @@ -184,53 +376,67 @@ export async function upsertLessonEvents( writeState, onProgress, logLabel, + concurrency = SYNC_CONCURRENCY, } = params; let accessToken = await getAccessToken(); + const refreshAccessToken = async () => { + accessToken = await getAccessToken(); + return accessToken; + }; let created = 0; let updated = 0; + let skipped = 0; let failed = initialFailed; + let completed = 0; + let persistChain: Promise = Promise.resolve(); - for (let i = 0; i < events.length; i++) { - const event = events[i]; + const persistState = () => { + persistChain = persistChain.then(() => + writeState({ eventMap, lastSyncAt, lastSyncOrigin: origin }), + ); + return persistChain; + }; + + await mapPool(events, concurrency, async (event) => { const key = mapKey(origin, event.seqtaKey); const existingId = getStoredEventId(eventMap[key]); - const progressCurrent = staleEntryCount + i + 1; - const progressMessage = `Syncing events (${i + 1}/${events.length})…`; + const desiredFingerprint = eventFingerprint(event); try { - const remoteId = await upsert(accessToken, existingId, event, async () => { - accessToken = await getAccessToken(); - return accessToken; - }); - if (existingId) updated += 1; - else created += 1; - eventMap[key] = { - id: remoteId, - date: lessonDateForEvent(event.startDateTime, event.seqtaKey), - }; - - reportSyncProgress(onProgress, { - phase: "upserting", - current: progressCurrent, - total: totalSteps, - message: progressMessage, - }); - - if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) { - await writeState({ eventMap, lastSyncAt, lastSyncOrigin: origin }); + if (existingId && getStoredFingerprint(eventMap[key]) === desiredFingerprint) { + skipped += 1; + } else { + const remoteId = await upsert(accessToken, existingId, event, refreshAccessToken); + if (existingId) updated += 1; + else created += 1; + eventMap[key] = { + id: remoteId, + date: lessonDateForEvent(event.startDateTime, event.seqtaKey), + fingerprint: desiredFingerprint, + }; } } catch (err) { verboseLog(`[BetterSEQTA+] ${logLabel} event sync failed:`, err); failed += 1; - reportSyncProgress(onProgress, { - phase: "upserting", - current: progressCurrent, - total: totalSteps, - message: progressMessage, - }); } + + completed += 1; + reportSyncProgress(onProgress, { + phase: "upserting", + current: staleEntryCount + completed, + total: totalSteps, + message: `Syncing events (${completed}/${events.length})…`, + }); + + if (completed % EVENT_MAP_PERSIST_EVERY === 0) { + await persistState(); + } + }); + + if (events.length > 0) { + await persistState(); } - return { created, updated, failed, accessToken }; + return { created, updated, skipped, failed, accessToken }; } diff --git a/src/seqta/utils/calendarSync/providerStorage.ts b/src/seqta/utils/calendarSync/providerStorage.ts new file mode 100644 index 00000000..526fb536 --- /dev/null +++ b/src/seqta/utils/calendarSync/providerStorage.ts @@ -0,0 +1,53 @@ +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; + /** Google calendar id for the app-owned "BetterSEQTA+ Timetable" secondary calendar. */ + calendarId?: string; + 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..3f1156fa --- /dev/null +++ b/src/seqta/utils/calendarSync/remoteEvents.ts @@ -0,0 +1,380 @@ +import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP, GOOGLE_CALENDAR_API } from "@/config/googleCalendar"; +import { + BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY, + OUTLOOK_GRAPH_API, +} from "@/config/outlookCalendar"; +import { + eventFingerprint, + parseOutlookSeqtaKey, +} from "@/seqta/utils/calendarSync/eventFingerprint"; +import type { SyncDateRange } from "@/seqta/utils/googleCalendar/syncDateRange"; + +export type RemoteSyncedEvent = { + seqtaKey: string; + id: string; + fingerprint: string; + date: string; +}; + +export type ListSyncedEventsOptions = { + /** Include category/calendar events that are missing a seqta key (legacy orphans). */ + includeUnkeyed?: boolean; +}; + +const MAX_RETRIES = 4; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryableStatus(status: number): boolean { + return status === 429 || status === 500 || status === 502 || status === 503 || status === 403; +} + +async function authorizedFetch( + accessToken: string, + url: string, + init: RequestInit, + refreshAccessToken?: () => Promise, +): Promise { + let token = accessToken; + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const res = await fetch(url, { + ...init, + headers: { Authorization: `Bearer ${token}`, ...init.headers }, + }); + if (res.status === 401 && refreshAccessToken) { + token = await refreshAccessToken(); + continue; + } + if (isRetryableStatus(res.status) && attempt < MAX_RETRIES - 1) { + await sleep(250 * 2 ** attempt + Math.floor(Math.random() * 100)); + continue; + } + return res; + } + return fetch(url, { + ...init, + headers: { Authorization: `Bearer ${token}`, ...init.headers }, + }); +} + +async function upsertRemoteEvent( + 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})`); +} + +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, + ); +} + +function toRfc3339Start(date: string): string { + return `${date}T00:00:00Z`; +} + +function toRfc3339EndExclusive(date: string): string { + const d = new Date(`${date}T12:00:00Z`); + d.setUTCDate(d.getUTCDate() + 1); + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${day}T00:00:00Z`; +} + +function dateFromDateTime(value: string | undefined): string { + if (!value) return ""; + return value.slice(0, 10); +} + +type GoogleListItem = { + id?: string; + summary?: string; + location?: string; + description?: string; + start?: { dateTime?: string; date?: string; timeZone?: string }; + end?: { dateTime?: string; date?: string; timeZone?: string }; + extendedProperties?: { private?: Record }; +}; + +function googleItemToRemote( + item: GoogleListItem, + includeUnkeyed: boolean, +): RemoteSyncedEvent | null { + if (!item.id) return null; + const seqtaKey = item.extendedProperties?.private?.[BSPLUS_GOOGLE_CALENDAR_EVENT_PROP] ?? ""; + if (!seqtaKey && !includeUnkeyed) return null; + const startDateTime = item.start?.dateTime ?? (item.start?.date ? `${item.start.date}T00:00:00` : ""); + const endDateTime = item.end?.dateTime ?? (item.end?.date ? `${item.end.date}T00:00:00` : ""); + const timeZone = item.start?.timeZone ?? item.end?.timeZone ?? "UTC"; + return { + seqtaKey, + id: item.id, + date: dateFromDateTime(startDateTime) || item.start?.date || "", + fingerprint: eventFingerprint({ + summary: item.summary ?? "", + location: item.location, + description: item.description, + startDateTime, + endDateTime, + timeZone, + }), + }; +} + +export async function listGoogleSyncedEvents( + accessToken: string, + calendarId: string, + range: SyncDateRange, + refreshAccessToken?: () => Promise, + options: ListSyncedEventsOptions = {}, +): Promise { + const includeUnkeyed = options.includeUnkeyed === true; + const encodedCalendar = encodeURIComponent(calendarId); + const out: RemoteSyncedEvent[] = []; + let pageToken: string | undefined; + + do { + const params = new URLSearchParams({ + singleEvents: "true", + orderBy: "startTime", + maxResults: "2500", + timeMin: toRfc3339Start(range.from), + timeMax: toRfc3339EndExclusive(range.until), + fields: + "nextPageToken,items(id,summary,location,description,start,end,extendedProperties)", + }); + if (pageToken) params.set("pageToken", pageToken); + + const res = await authorizedFetch( + accessToken, + `${GOOGLE_CALENDAR_API}/calendars/${encodedCalendar}/events?${params}`, + { method: "GET" }, + refreshAccessToken, + ); + const json = (await res.json().catch(() => ({}))) as { + items?: GoogleListItem[]; + nextPageToken?: string; + error?: { message?: string }; + }; + if (!res.ok) { + throw new Error(json?.error?.message ?? `Google Calendar list failed (${res.status})`); + } + + for (const item of json.items ?? []) { + const mapped = googleItemToRemote(item, includeUnkeyed); + if (mapped) out.push(mapped); + } + pageToken = json.nextPageToken; + } while (pageToken); + + return out; +} + +type OutlookListItem = { + id?: string; + subject?: string; + body?: { content?: string; contentType?: string }; + location?: { displayName?: string }; + start?: { dateTime?: string; timeZone?: string }; + end?: { dateTime?: string; timeZone?: string }; + categories?: string[]; +}; + +function outlookItemToRemote( + item: OutlookListItem, + includeUnkeyed: boolean, +): RemoteSyncedEvent | null { + if (!item.id) return null; + const categories = item.categories ?? []; + if (!categories.includes(BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY)) return null; + const bodyContent = item.body?.content ?? ""; + const seqtaKey = parseOutlookSeqtaKey(bodyContent) ?? ""; + if (!seqtaKey && !includeUnkeyed) return null; + + const startDateTime = (item.start?.dateTime ?? "").replace(/\.\d+$/, ""); + const endDateTime = (item.end?.dateTime ?? "").replace(/\.\d+$/, ""); + const timeZone = item.start?.timeZone ?? item.end?.timeZone ?? "UTC"; + + return { + seqtaKey, + id: item.id, + date: dateFromDateTime(startDateTime), + fingerprint: eventFingerprint({ + summary: item.subject ?? "", + location: item.location?.displayName, + description: outlookDescriptionForFingerprint(bodyContent), + startDateTime, + endDateTime, + timeZone, + }), + }; +} + +/** Strip Outlook Key line so fingerprint matches local mapped event description. */ +function outlookDescriptionForFingerprint(bodyContent: string): string { + return bodyContent + .split("\n") + .filter((line) => !/^Key:\s*/.test(line)) + .join("\n") + .trim(); +} + +export async function listOutlookSyncedEvents( + accessToken: string, + range: SyncDateRange, + refreshAccessToken?: () => Promise, + options: ListSyncedEventsOptions = {}, +): Promise { + const includeUnkeyed = options.includeUnkeyed === true; + const out: RemoteSyncedEvent[] = []; + const params = new URLSearchParams({ + startDateTime: toRfc3339Start(range.from), + endDateTime: toRfc3339EndExclusive(range.until), + $top: "100", + $select: "id,subject,body,location,start,end,categories", + }); + + let nextUrl: string | undefined = + `${OUTLOOK_GRAPH_API}/me/calendarView?${params.toString()}`; + + while (nextUrl) { + const res = await authorizedFetch( + accessToken, + nextUrl, + { + method: "GET", + headers: { Prefer: 'outlook.body-content-type="text"' }, + }, + refreshAccessToken, + ); + const json = (await res.json().catch(() => ({}))) as { + value?: OutlookListItem[]; + "@odata.nextLink"?: string; + error?: { message?: string }; + }; + if (!res.ok) { + throw new Error(json?.error?.message ?? `Outlook Calendar list failed (${res.status})`); + } + + for (const item of json.value ?? []) { + const mapped = outlookItemToRemote(item, includeUnkeyed); + if (mapped) out.push(mapped); + } + nextUrl = json["@odata.nextLink"]; + } + + return out; +} 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..c1f031a4 --- /dev/null +++ b/src/seqta/utils/calendarSync/syncEngine.ts @@ -0,0 +1,390 @@ +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, + clearOriginEventMapEntries, + collectOriginDeleteEntries, + deleteTrackedLessonEvents, + emptyLessonsSyncResult, + entriesToPrune, + mergeRemoteEventsIntoMap, + notConfiguredSyncResult, + notConnectedSyncResult, + reconcileRangeForMode, + reportSyncProgress, + upsertLessonEvents, +} from "@/seqta/utils/calendarSync/lessonSyncShared"; +import { + deleteGoogleCalendarEvent, + deleteOutlookCalendarEvent, + listGoogleSyncedEvents, + listOutlookSyncedEvents, + upsertGoogleCalendarEvent, + upsertOutlookCalendarEvent, + type ListSyncedEventsOptions, + type RemoteSyncedEvent, +} from "@/seqta/utils/calendarSync/remoteEvents"; +import { + googleApiEventBody, + mapLessonsToGoogleEvents, + outlookGraphEventBody, +} from "@/seqta/utils/googleCalendar/eventMapper"; +import { ensureGoogleAppCalendar } from "@/seqta/utils/googleCalendar/calendarProvisioning"; +import { + readGoogleCalendarState, + writeGoogleCalendarState, +} from "@/seqta/utils/googleCalendar/storage"; +import { + syncWindowRange, + trailingWeekRange, + wideCleanupRange, + type SyncDateRange, +} from "@/seqta/utils/googleCalendar/syncDateRange"; +import type { + GoogleCalendarDeleteResult, + GoogleCalendarEventInput, + GoogleCalendarSyncOptions, + GoogleCalendarSyncRequest, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; +import { + readOutlookCalendarState, + writeOutlookCalendarState, +} from "@/seqta/utils/outlookCalendar/storage"; +import { verboseLog } from "@/utils/verboseLog"; + +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; + listSyncedEvents: ( + accessToken: string, + range: SyncDateRange, + refreshAccessToken: () => Promise, + options?: ListSyncedEventsOptions, + ) => Promise; + toApiBody: (event: GoogleCalendarEventInput) => Record; +}; + +async function getOrProvisionGoogleCalendarId(accessToken: string): Promise { + const state = await readGoogleCalendarState(); + if (state.calendarId) return state.calendarId; + + const calendarId = await ensureGoogleAppCalendar(accessToken); + await writeGoogleCalendarState({ calendarId }); + return calendarId; +} + +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: async (accessToken, eventId, refreshAccessToken) => + deleteGoogleCalendarEvent( + accessToken, + await getOrProvisionGoogleCalendarId(accessToken), + eventId, + refreshAccessToken, + ), + upsertEvent: async (accessToken, existingId, body, refreshAccessToken) => + upsertGoogleCalendarEvent( + accessToken, + await getOrProvisionGoogleCalendarId(accessToken), + existingId, + body, + refreshAccessToken, + ), + listSyncedEvents: async (accessToken, range, refreshAccessToken, options) => + listGoogleSyncedEvents( + accessToken, + await getOrProvisionGoogleCalendarId(accessToken), + range, + refreshAccessToken, + options, + ), + 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), + listSyncedEvents: (accessToken, range, refreshAccessToken, options) => + listOutlookSyncedEvents(accessToken, range, refreshAccessToken, options), + 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); + + 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 ?? {}) }; + let accessToken = await getAccessToken(); + const refreshAccessToken = async () => { + accessToken = await getAccessToken(); + return accessToken; + }; + + const reconcileRange = reconcileRangeForMode(mode, weeksAhead, { + syncWindowRange, + trailingWeekRange, + }); + + try { + const remoteEvents = await provider.listSyncedEvents( + accessToken, + reconcileRange, + refreshAccessToken, + ); + mergeRemoteEventsIntoMap(eventMap, request.origin, remoteEvents, eventMapKey); + } catch (err) { + verboseLog(`[BetterSEQTA+] ${provider.label} remote event list failed:`, err); + } + + const currentMapKeys = new Set(events.map((event) => eventMapKey(request.origin, event.seqtaKey))); + const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys); + const totalSteps = Math.max(staleEntries.length + events.length, 1); + const lastSyncAt = Date.now(); + + const staleResult = await deleteTrackedLessonEvents( + staleEntries, + eventMap, + getAccessToken, + provider.deleteEvent, + provider.writeState, + { + onProgress: options.onProgress, + progressTotal: totalSteps, + logLabel: provider.label, + }, + ); + + if (events.length === 0 && mode === "full") { + await provider.writeState({ + eventMap, + lastSyncAt, + lastSyncOrigin: request.origin, + }); + reportSyncProgress(options.onProgress, { + phase: "done", + current: totalSteps, + total: totalSteps, + message: "Sync complete", + }); + if (staleResult.deleted > 0) { + return buildLessonSyncResult(0, 0, staleResult.deleted, 0, staleResult.failed, lastSyncAt); + } + return emptyLessonsSyncResult(); + } + + const upsertResult = await upsertLessonEvents({ + events, + eventMap, + origin: request.origin, + staleEntryCount: staleEntries.length, + totalSteps, + lastSyncAt, + initialFailed: staleResult.failed, + getAccessToken, + mapKey: eventMapKey, + upsert: (token, existingId, event, refresh) => + provider.upsertEvent(token, existingId, provider.toApiBody(event), refresh), + 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.skipped, + 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 }; + } + + reportSyncProgress(options.onProgress, { + phase: "preparing", + current: 0, + total: 1, + message: "Preparing removal…", + }); + + const eventMap = { ...(state.eventMap ?? {}) }; + let accessToken = await getAccessToken(); + const refreshAccessToken = async () => { + accessToken = await getAccessToken(); + return accessToken; + }; + + let remoteEvents: RemoteSyncedEvent[] = []; + try { + remoteEvents = await provider.listSyncedEvents( + accessToken, + wideCleanupRange(), + refreshAccessToken, + { includeUnkeyed: true }, + ); + mergeRemoteEventsIntoMap(eventMap, origin, remoteEvents, eventMapKey); + } catch (err) { + verboseLog(`[BetterSEQTA+] ${provider.label} cleanup list failed:`, err); + } + + const entries = collectOriginDeleteEntries(eventMap, origin, remoteEvents, eventMapKey); + if (entries.length === 0) { + clearOriginEventMapEntries(eventMap, origin); + await provider.writeState({ eventMap }); + return { success: true, configured: true, connected: true, deleted: 0, failed: 0 }; + } + + const { deleted, failed } = await deleteTrackedLessonEvents( + entries, + eventMap, + getAccessToken, + provider.deleteEvent, + provider.writeState, + { + persistProgress: true, + onProgress: options.onProgress, + progressTotal: entries.length, + logLabel: provider.label, + }, + ); + + // Only wipe remaining origin keys when every delete succeeded — keep failed IDs for retry. + if (failed === 0) { + clearOriginEventMapEntries(eventMap, origin); + } + await provider.writeState({ eventMap }); + + reportSyncProgress(options.onProgress, { + 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/calendarProvisioning.test.ts b/src/seqta/utils/googleCalendar/calendarProvisioning.test.ts new file mode 100644 index 00000000..d5816881 --- /dev/null +++ b/src/seqta/utils/googleCalendar/calendarProvisioning.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +jest.mock("@/config/googleCalendar", () => ({ + BSPLUS_GOOGLE_CALENDAR_DESCRIPTION: "desc", + BSPLUS_GOOGLE_CALENDAR_NAME: "BetterSEQTA+ Timetable", + GOOGLE_CALENDAR_API: "https://www.googleapis.com/calendar/v3", +})); + +import { ensureGoogleAppCalendar } from "./calendarProvisioning"; + +const fetchMock = jest.fn(); +global.fetch = fetchMock as typeof fetch; + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +describe("ensureGoogleAppCalendar", () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + it("reuses a stored calendar id when it still exists", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ id: "stored-id", summary: "BetterSEQTA+ Timetable" })); + + await expect(ensureGoogleAppCalendar("token", "stored-id")).resolves.toBe("stored-id"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("finds an existing app calendar by name when stored id is missing", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + items: [{ id: "found-id", summary: "BetterSEQTA+ Timetable" }], + }), + ); + + await expect(ensureGoogleAppCalendar("token")).resolves.toBe("found-id"); + expect(fetchMock).toHaveBeenCalledWith( + "https://www.googleapis.com/calendar/v3/users/me/calendarList", + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer token" }) }), + ); + }); + + it("creates a calendar when none exists", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ items: [] })) + .mockResolvedValueOnce(jsonResponse({ id: "new-id" })); + + await expect(ensureGoogleAppCalendar("token")).resolves.toBe("new-id"); + expect(fetchMock).toHaveBeenCalledWith( + "https://www.googleapis.com/calendar/v3/calendars", + expect.objectContaining({ method: "POST" }), + ); + }); +}); diff --git a/src/seqta/utils/googleCalendar/calendarProvisioning.ts b/src/seqta/utils/googleCalendar/calendarProvisioning.ts new file mode 100644 index 00000000..77756001 --- /dev/null +++ b/src/seqta/utils/googleCalendar/calendarProvisioning.ts @@ -0,0 +1,83 @@ +import { + BSPLUS_GOOGLE_CALENDAR_DESCRIPTION, + BSPLUS_GOOGLE_CALENDAR_NAME, + GOOGLE_CALENDAR_API, +} from "@/config/googleCalendar"; + +type GoogleCalendarResource = { id?: string; summary?: string }; +type GoogleCalendarListResponse = { items?: GoogleCalendarResource[] }; +type GoogleApiError = { error?: { message?: string } }; + +async function googleCalendarFetch( + accessToken: string, + path: string, + init?: RequestInit, +): Promise<{ ok: boolean; status: number; data: T }> { + const res = await fetch(`${GOOGLE_CALENDAR_API}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${accessToken}`, + ...(init?.body ? { "Content-Type": "application/json" } : {}), + ...init?.headers, + }, + }); + const data = (await res.json().catch(() => ({}))) as T & GoogleApiError; + return { ok: res.ok, status: res.status, data }; +} + +async function calendarExists(accessToken: string, calendarId: string): Promise { + const { ok, status } = await googleCalendarFetch( + accessToken, + `/calendars/${encodeURIComponent(calendarId)}`, + ); + return ok || status === 404 ? ok : false; +} + +async function findExistingAppCalendar(accessToken: string): Promise { + const { ok, data } = await googleCalendarFetch( + accessToken, + "/users/me/calendarList", + ); + if (!ok) return undefined; + + const match = (data.items ?? []).find((item) => item.summary === BSPLUS_GOOGLE_CALENDAR_NAME); + return match?.id; +} + +async function createAppCalendar(accessToken: string): Promise { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const { ok, status, data } = await googleCalendarFetch( + accessToken, + "/calendars", + { + method: "POST", + body: JSON.stringify({ + summary: BSPLUS_GOOGLE_CALENDAR_NAME, + description: BSPLUS_GOOGLE_CALENDAR_DESCRIPTION, + timeZone, + }), + }, + ); + + if (!ok || !data.id) { + throw new Error( + data.error?.message ?? `Could not create BetterSEQTA+ calendar (${status}).`, + ); + } + return data.id; +} + +/** Resolves the app-owned calendar id, reusing stored or existing calendars when possible. */ +export async function ensureGoogleAppCalendar( + accessToken: string, + storedCalendarId?: string, +): Promise { + if (storedCalendarId && (await calendarExists(accessToken, storedCalendarId))) { + return storedCalendarId; + } + + const existing = await findExistingAppCalendar(accessToken); + if (existing) return existing; + + return createAppCalendar(accessToken); +} 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.test.ts b/src/seqta/utils/googleCalendar/eventMapper.test.ts index f6e2ef94..7db17c67 100644 --- a/src/seqta/utils/googleCalendar/eventMapper.test.ts +++ b/src/seqta/utils/googleCalendar/eventMapper.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@jest/globals"; import { lessonToGoogleEvent, mapLessonsToGoogleEvents, + outlookGraphEventBody, seqtaLessonKey, shouldSyncLesson, } from "./eventMapper"; @@ -62,3 +63,14 @@ describe("mapLessonsToGoogleEvents", () => { expect(events).toHaveLength(1); }); }); + +describe("outlookGraphEventBody", () => { + it("embeds Key: seqtaKey in the body for remote reconcile", () => { + const event = lessonToGoogleEvent(ORIGIN, baseLesson, "Australia/Perth"); + expect(event).not.toBeNull(); + const body = outlookGraphEventBody(event!); + const content = (body.body as { content: string }).content; + expect(content).toContain(`Key: ${ORIGIN}:cal:12345`); + expect(content).toContain("Synced by BetterSEQTA+"); + }); +}); diff --git a/src/seqta/utils/googleCalendar/eventMapper.ts b/src/seqta/utils/googleCalendar/eventMapper.ts index 5d708fc4..755110b8 100644 --- a/src/seqta/utils/googleCalendar/eventMapper.ts +++ b/src/seqta/utils/googleCalendar/eventMapper.ts @@ -1,4 +1,6 @@ import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar"; +import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar"; +import { outlookDescriptionWithKey } from "@/seqta/utils/calendarSync/eventFingerprint"; import type { GoogleCalendarEventInput, SeqtaTimetableLesson } from "./types"; const SKIP_TYPES = new Set(["note", "holiday", "assembly-note"]); @@ -93,3 +95,20 @@ export function googleApiEventBody(event: GoogleCalendarEventInput): Record { + const body: Record = { + subject: event.summary, + body: { + contentType: "text", + content: outlookDescriptionWithKey(event.description, event.seqtaKey), + }, + 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..213b274f 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,25 +26,27 @@ 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 { return date >= range.from && date <= range.until; } + +/** Wide range used when removing all synced events (covers past + future terms). */ +export function wideCleanupRange(years = 3): SyncDateRange { + const now = new Date(); + const from = new Date(now); + from.setFullYear(from.getFullYear() - years); + const until = new Date(now); + until.setFullYear(until.getFullYear() + years); + return { from: toISODate(from), until: toISODate(until) }; +} diff --git a/src/seqta/utils/googleCalendar/syncEngine.test.ts b/src/seqta/utils/googleCalendar/syncEngine.test.ts index 595ef9d7..16b52238 100644 --- a/src/seqta/utils/googleCalendar/syncEngine.test.ts +++ b/src/seqta/utils/googleCalendar/syncEngine.test.ts @@ -3,39 +3,56 @@ import type { SeqtaTimetableLesson } from "./types"; jest.mock("@/config/googleCalendar", () => ({ isGoogleCalendarConfigured: jest.fn(() => true), + BSPLUS_GOOGLE_CALENDAR_EVENT_PROP: "bsplusSeqtaKey", + GOOGLE_CALENDAR_API: "https://www.googleapis.com/calendar/v3", })); jest.mock("@/utils/verboseLog", () => ({ 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(), + listGoogleSyncedEvents: jest.fn(async () => []), })); import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; import { deleteGoogleCalendarEvent, + listGoogleSyncedEvents, upsertGoogleCalendarEvent, -} from "@/seqta/utils/googleCalendar/upsertEvent"; -import { deleteSyncedEventsFromGoogleCalendar, syncLessonsToGoogleCalendar } from "./syncEngine"; +} from "@/seqta/utils/calendarSync/remoteEvents"; +import { + deleteSyncedEventsFromGoogleCalendar, + syncLessonsToGoogleCalendar, +} from "@/seqta/utils/calendarSync/syncEngine"; +import { eventFingerprint } from "@/seqta/utils/calendarSync/eventFingerprint"; +import { lessonToGoogleEvent } from "@/seqta/utils/googleCalendar/eventMapper"; + +import { syncWindowRange } from "@/seqta/utils/googleCalendar/syncDateRange"; const ORIGIN = "https://school.seqta.com.au"; const getAccessToken = async () => "test-token"; +const syncDate = syncWindowRange(12).from; const baseLesson: SeqtaTimetableLesson = { - date: "2026-06-27", + date: syncDate, from: "09:00:00", until: "10:00:00", description: "10 Mathematics", @@ -51,13 +68,15 @@ describe("syncLessonsToGoogleCalendar", () => { jest.clearAllMocks(); jest.mocked(readGoogleCalendarState).mockResolvedValue({ refreshToken: "refresh", + calendarId: "app-calendar-id", eventMap: { - [`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "google-existing", date: "2026-06-27" }, + [`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "google-existing", date: syncDate }, [`${ORIGIN}::${ORIGIN}:cal:99999`]: { id: "google-stale", date: "2020-01-06" }, }, }); jest.mocked(upsertGoogleCalendarEvent).mockResolvedValue("google-existing"); jest.mocked(deleteGoogleCalendarEvent).mockResolvedValue(undefined); + jest.mocked(listGoogleSyncedEvents).mockResolvedValue([]); }); it("updates existing events and removes stale tracked events on full sync", async () => { @@ -66,6 +85,7 @@ describe("syncLessonsToGoogleCalendar", () => { getAccessToken, ); + expect(listGoogleSyncedEvents).toHaveBeenCalled(); expect(deleteGoogleCalendarEvent).toHaveBeenCalled(); expect(upsertGoogleCalendarEvent).toHaveBeenCalledTimes(1); expect(result).toMatchObject({ @@ -79,6 +99,7 @@ describe("syncLessonsToGoogleCalendar", () => { it("creates events that are not yet tracked", async () => { jest.mocked(readGoogleCalendarState).mockResolvedValue({ refreshToken: "refresh", + calendarId: "app-calendar-id", eventMap: {}, }); jest.mocked(upsertGoogleCalendarEvent).mockResolvedValue("google-new"); @@ -97,6 +118,64 @@ describe("syncLessonsToGoogleCalendar", () => { }); }); + it("recovers remote IDs when the local map is empty and skips unchanged", async () => { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const mapped = lessonToGoogleEvent(ORIGIN, baseLesson, timeZone); + expect(mapped).not.toBeNull(); + const fp = eventFingerprint(mapped!); + + jest.mocked(readGoogleCalendarState).mockResolvedValue({ + refreshToken: "refresh", + calendarId: "app-calendar-id", + eventMap: {}, + }); + jest.mocked(listGoogleSyncedEvents).mockResolvedValue([ + { + seqtaKey: `${ORIGIN}:cal:12345`, + id: "recovered-id", + date: syncDate, + fingerprint: fp, + }, + ]); + + const result = await syncLessonsToGoogleCalendar( + { origin: ORIGIN, lessons: [baseLesson], mode: "full" }, + getAccessToken, + ); + + expect(upsertGoogleCalendarEvent).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + success: true, + created: 0, + updated: 0, + skipped: 1, + }); + }); + + it("deletes cancelled lessons that remain inside the sync window", async () => { + jest.mocked(readGoogleCalendarState).mockResolvedValue({ + refreshToken: "refresh", + calendarId: "app-calendar-id", + eventMap: { + [`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "keep", date: syncDate }, + [`${ORIGIN}::${ORIGIN}:cal:cancelled`]: { id: "gone", date: syncDate }, + }, + }); + + const result = await syncLessonsToGoogleCalendar( + { origin: ORIGIN, lessons: [baseLesson], mode: "full" }, + getAccessToken, + ); + + expect(deleteGoogleCalendarEvent).toHaveBeenCalledWith( + "test-token", + "app-calendar-id", + "gone", + expect.any(Function), + ); + expect(result.deleted).toBeGreaterThanOrEqual(1); + }); + it("does not delete events during incremental sync", async () => { const result = await syncLessonsToGoogleCalendar( { origin: ORIGIN, lessons: [baseLesson], mode: "incremental" }, @@ -130,6 +209,7 @@ describe("deleteSyncedEventsFromGoogleCalendar", () => { jest.clearAllMocks(); jest.mocked(readGoogleCalendarState).mockResolvedValue({ refreshToken: "refresh", + calendarId: "app-calendar-id", eventMap: { [`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "google-1", date: "2026-06-27" }, [`${ORIGIN}::${ORIGIN}:cal:99999`]: { id: "google-2", date: "2026-06-28" }, @@ -142,6 +222,7 @@ describe("deleteSyncedEventsFromGoogleCalendar", () => { it("deletes only events for the requested origin", async () => { const result = await deleteSyncedEventsFromGoogleCalendar(ORIGIN, getAccessToken); + expect(listGoogleSyncedEvents).toHaveBeenCalled(); expect(deleteGoogleCalendarEvent).toHaveBeenCalledTimes(2); expect(result).toMatchObject({ success: true, @@ -149,4 +230,31 @@ describe("deleteSyncedEventsFromGoogleCalendar", () => { failed: 0, }); }); + + it("also deletes remote orphans found by list", async () => { + jest.mocked(readGoogleCalendarState).mockResolvedValue({ + refreshToken: "refresh", + calendarId: "app-calendar-id", + eventMap: {}, + }); + jest.mocked(listGoogleSyncedEvents).mockResolvedValue([ + { + seqtaKey: `${ORIGIN}:cal:1`, + id: "remote-a", + date: "2026-06-27", + fingerprint: "fp", + }, + { + seqtaKey: "", + id: "orphan-b", + date: "2026-06-28", + fingerprint: "fp", + }, + ]); + + const result = await deleteSyncedEventsFromGoogleCalendar(ORIGIN, getAccessToken); + + expect(deleteGoogleCalendarEvent).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ success: true, deleted: 2, failed: 0 }); + }); }); 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 79b23ed9..00000000 --- a/src/seqta/utils/googleCalendar/syncRunner.ts +++ /dev/null @@ -1,76 +0,0 @@ -import browser from "webextension-polyfill"; -import { - fetchTimetableForSync, - fetchTimetableLessons, - trailingWeekRange, -} from "@/seqta/utils/googleCalendar/fetchTimetable"; -import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; -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(); - - 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 4758f642..00000000 --- a/src/seqta/utils/outlookCalendar/syncRunner.ts +++ /dev/null @@ -1,76 +0,0 @@ -import browser from "webextension-polyfill"; -import { - fetchTimetableForSync, - fetchTimetableLessons, - trailingWeekRange, -} from "@/seqta/utils/googleCalendar/fetchTimetable"; -import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; -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(); - - 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..a4e578a8 100644 --- a/src/seqta/utils/outlookCalendar/upsertEvent.test.ts +++ b/src/seqta/utils/outlookCalendar/upsertEvent.test.ts @@ -1,48 +1,132 @@ 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, + listGoogleSyncedEvents, + listOutlookSyncedEvents, + upsertOutlookCalendarEvent, +} from "@/seqta/utils/calendarSync/remoteEvents"; +import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar"; +import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar"; + +describe("outlook calendar remote events", () => { beforeEach(() => { - 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(); }); + + it("lists Outlook synced events and paginates", async () => { + mockFetch + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + value: [ + { + id: "o1", + subject: "Math", + body: { content: "Synced by BetterSEQTA+\nKey: https://school.seqta.com.au:cal:1" }, + start: { dateTime: "2026-07-13T09:00:00", timeZone: "UTC" }, + end: { dateTime: "2026-07-13T10:00:00", timeZone: "UTC" }, + categories: [BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY], + }, + ], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/calendarView?$skiptoken=2", + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + value: [ + { + id: "o2", + subject: "English", + body: { content: "Synced by BetterSEQTA+\nKey: https://school.seqta.com.au:cal:2" }, + start: { dateTime: "2026-07-14T09:00:00", timeZone: "UTC" }, + end: { dateTime: "2026-07-14T10:00:00", timeZone: "UTC" }, + categories: [BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY], + }, + ], + }), + { status: 200 }, + ), + ); + + const events = await listOutlookSyncedEvents("token", { + from: "2026-07-13", + until: "2026-07-20", + }); + + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + id: "o1", + seqtaKey: "https://school.seqta.com.au:cal:1", + }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); + +describe("google calendar remote list", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it("lists Google synced events using private extended properties", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "g1", + summary: "Math", + description: "Synced by BetterSEQTA+", + start: { dateTime: "2026-07-13T09:00:00", timeZone: "UTC" }, + end: { dateTime: "2026-07-13T10:00:00", timeZone: "UTC" }, + extendedProperties: { + private: { [BSPLUS_GOOGLE_CALENDAR_EVENT_PROP]: "https://school.seqta.com.au:cal:1" }, + }, + }, + { + id: "g-skip", + summary: "Unrelated", + start: { dateTime: "2026-07-13T11:00:00", timeZone: "UTC" }, + end: { dateTime: "2026-07-13T12:00:00", timeZone: "UTC" }, + }, + ], + }), + { status: 200 }, + ), + ); + + const events = await listGoogleSyncedEvents("token", "cal-id", { + from: "2026-07-13", + until: "2026-07-20", + }); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + id: "g1", + seqtaKey: "https://school.seqta.com.au:cal:1", + date: "2026-07-13", + }); + }); }); 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); }