From e6b8be6821378385486450d885c9d747d31c6cce Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sat, 27 Jun 2026 11:51:48 +0930 Subject: [PATCH 1/6] feat: add Google Calendar timetable sync via accounts OAuth OAuth flows through accounts.betterseqta.org with token exchange server-side. Sync runs in the content script to avoid MV3 service worker timeouts. Adds Svelte calendar sync UI on the timetable page, extension asset URL helper, and excludes calendar tokens from cloud sync. Co-authored-by: Cursor --- .env.example | 5 + src/background.ts | 3 + src/background/googleCalendar.ts | 301 ++++++++ src/config/googleCalendar.ts | 43 ++ src/env.d.ts | 1 + src/interface/index.ts | 3 +- src/lib/extensionAssetUrl.test.ts | 18 + src/lib/extensionAssetUrl.ts | 18 + src/manifests/manifest.json | 17 +- .../timetable/CalendarDisconnectModal.svelte | 140 ++++ .../timetable/CalendarSyncControl.svelte | 705 ++++++++++++++++++ .../built-in/timetable/calendarSyncHost.css | 23 + .../built-in/timetable/calendarSyncTheme.ts | 82 ++ .../built-in/timetable/calendarSyncUi.ts | 53 ++ src/plugins/built-in/timetable/index.ts | 3 + src/plugins/monofile.ts | 5 +- src/seqta/utils/Loaders/LoadEngageHomePage.ts | 7 +- src/seqta/utils/Loaders/LoadHomePage.ts | 5 +- src/seqta/utils/SendNewsPage.ts | 5 +- src/seqta/utils/cloudSettingsSync.ts | 2 + .../utils/googleCalendar/accountsToken.ts | 76 ++ .../utils/googleCalendar/eventMapper.test.ts | 64 ++ src/seqta/utils/googleCalendar/eventMapper.ts | 95 +++ .../utils/googleCalendar/fetchTimetable.ts | 82 ++ src/seqta/utils/googleCalendar/storage.ts | 39 + src/seqta/utils/googleCalendar/syncEngine.ts | 100 +++ src/seqta/utils/googleCalendar/types.ts | 47 ++ src/seqta/utils/googleCalendar/upsertEvent.ts | 44 ++ vite.config.ts | 15 +- 29 files changed, 1984 insertions(+), 17 deletions(-) create mode 100644 .env.example create mode 100644 src/background/googleCalendar.ts create mode 100644 src/config/googleCalendar.ts create mode 100644 src/lib/extensionAssetUrl.test.ts create mode 100644 src/lib/extensionAssetUrl.ts create mode 100644 src/plugins/built-in/timetable/CalendarDisconnectModal.svelte create mode 100644 src/plugins/built-in/timetable/CalendarSyncControl.svelte create mode 100644 src/plugins/built-in/timetable/calendarSyncHost.css create mode 100644 src/plugins/built-in/timetable/calendarSyncTheme.ts create mode 100644 src/plugins/built-in/timetable/calendarSyncUi.ts create mode 100644 src/seqta/utils/googleCalendar/accountsToken.ts create mode 100644 src/seqta/utils/googleCalendar/eventMapper.test.ts create mode 100644 src/seqta/utils/googleCalendar/eventMapper.ts create mode 100644 src/seqta/utils/googleCalendar/fetchTimetable.ts create mode 100644 src/seqta/utils/googleCalendar/storage.ts create mode 100644 src/seqta/utils/googleCalendar/syncEngine.ts create mode 100644 src/seqta/utils/googleCalendar/types.ts create mode 100644 src/seqta/utils/googleCalendar/upsertEvent.ts diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..ba69324b --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# 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 + +# GOOGLE_OAUTH_CLIENT_ID=your-id.apps.googleusercontent.com diff --git a/src/background.ts b/src/background.ts index 4be354c2..c4b5eda4 100644 --- a/src/background.ts +++ b/src/background.ts @@ -13,6 +13,7 @@ import { withSuppressedCloudAutoUpload, } from "./background/cloudSettingsAutoSync"; import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl"; +import { registerGoogleCalendarMessageHandlers } from "./background/googleCalendar"; /** * Session-only dev-mode override of the content API base. @@ -557,6 +558,8 @@ const MESSAGE_HANDLERS: Record = { }, }; +registerGoogleCalendarMessageHandlers(MESSAGE_HANDLERS, isTrustedSender); + browser.runtime.onMessage.addListener( // @ts-ignore - OnMessageListener expects literal true for async, we return boolean (request: any, sender: browser.Runtime.MessageSender, sendResponse: MessageSender) => { diff --git a/src/background/googleCalendar.ts b/src/background/googleCalendar.ts new file mode 100644 index 00000000..edec6328 --- /dev/null +++ b/src/background/googleCalendar.ts @@ -0,0 +1,301 @@ +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 type { + GoogleCalendarStatus, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; + +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(), + }); + + 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(); + return { + configured: isGoogleCalendarConfigured(), + connected: !!(state.refreshToken || state.accessToken), + lastSyncAt: state.lastSyncAt, + lastSyncOrigin: state.lastSyncOrigin, + }; +} + +export async function handleGoogleCalendarConnect(): Promise { + return connectGoogleCalendar(); +} + +export async function handleGoogleCalendarDisconnect(): Promise<{ success: boolean }> { + await clearGoogleCalendarState(); + 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; + }; +} diff --git a/src/config/googleCalendar.ts b/src/config/googleCalendar.ts new file mode 100644 index 00000000..8a24bd14 --- /dev/null +++ b/src/config/googleCalendar.ts @@ -0,0 +1,43 @@ +/** + * Google Calendar OAuth — public client config (extension). + * Client secret and token exchange live on accounts.betterseqta.org. + * See docs/GOOGLE_CALENDAR_ACCOUNTS_CALLBACK.md + */ + +const HARDCODED_GOOGLE_OAUTH_CLIENT_ID = + "270834969641-f6t7jtpu6j0cemse8updj3rkos7nl0hf.apps.googleusercontent.com"; + +const envClientId = + typeof __GOOGLE_OAUTH_CLIENT_ID__ !== "undefined" ? __GOOGLE_OAUTH_CLIENT_ID__ : ""; + +export const GOOGLE_OAUTH_CLIENT_ID: string = + envClientId.trim() || HARDCODED_GOOGLE_OAUTH_CLIENT_ID.trim(); + +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"; + +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_EVENT_PROP = "bsplusSeqtaKey"; + +/** Weeks of timetable to sync (from start of current week). */ +export const GOOGLE_CALENDAR_SYNC_WEEKS = 12; + +export function isGoogleCalendarConfigured(): boolean { + return GOOGLE_OAUTH_CLIENT_ID.length > 0; +} + +export const GOOGLE_CALENDAR_ACCOUNTS_NOT_READY_HINT = + "Google Calendar connect requires accounts.betterseqta.org — see docs/GOOGLE_CALENDAR_ACCOUNTS_CALLBACK.md"; + +export function googleOAuthRedirectUriHint(): string { + return `Authorized redirect URI in Google Cloud Console must be: ${GOOGLE_CALENDAR_OAUTH_CALLBACK}`; +} diff --git a/src/env.d.ts b/src/env.d.ts index b3a32339..6fda995d 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -2,3 +2,4 @@ declare const __ENABLE_GH_RELEASE_UPDATE_CHECK__: boolean; declare const __GH_RELEASE_REPO__: string; declare const __UPDATE_CHANNEL__: "stable" | "nightly"; declare const __BUILD_LABEL__: string; +declare const __GOOGLE_OAUTH_CLIENT_ID__: string; diff --git a/src/interface/index.ts b/src/interface/index.ts index 76a4e9c1..ff8d60b7 100644 --- a/src/interface/index.ts +++ b/src/interface/index.ts @@ -2,6 +2,7 @@ import "./index.css"; import Settings from "./pages/settings.svelte"; import IconFamily from "@/resources/fonts/IconFamily.woff"; import browser from "webextension-polyfill"; +import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import renderSvelte from "./main"; import { initializeSettingsState } from "@/seqta/utils/listeners/SettingsState"; @@ -13,7 +14,7 @@ function InjectCustomIcons() { style.innerHTML = ` @font-face { font-family: 'IconFamily'; - src: url('${browser.runtime.getURL(IconFamily)}') format('woff'); + src: url('${resolveExtensionAssetUrl(IconFamily)}') format('woff'); font-weight: normal; font-style: normal; }`; diff --git a/src/lib/extensionAssetUrl.test.ts b/src/lib/extensionAssetUrl.test.ts new file mode 100644 index 00000000..910de7b1 --- /dev/null +++ b/src/lib/extensionAssetUrl.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "@jest/globals"; +import { resolveExtensionAssetUrl } from "./extensionAssetUrl"; + +describe("resolveExtensionAssetUrl", () => { + it("returns already-resolved extension URLs unchanged", () => { + const url = + "chrome-extension://abc/assets/IconFamily-B8lopphU.woff"; + expect(resolveExtensionAssetUrl(url)).toBe(url); + }); + + it("repairs accidental double extension URL prefix", () => { + const doubled = + "chrome-extension://abc/chrome-extension://abc/assets/IconFamily-B8lopphU.woff"; + expect(resolveExtensionAssetUrl(doubled)).toBe( + "chrome-extension://abc/assets/IconFamily-B8lopphU.woff", + ); + }); +}); diff --git a/src/lib/extensionAssetUrl.ts b/src/lib/extensionAssetUrl.ts new file mode 100644 index 00000000..159edf58 --- /dev/null +++ b/src/lib/extensionAssetUrl.ts @@ -0,0 +1,18 @@ +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(/^\/+/, "")); +} diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index b4bfe4e4..a96aa207 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -15,17 +15,25 @@ "64": "resources/icons/icon-64.png" } }, - "permissions": ["tabs", "notifications", "storage"], - "host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"], + "permissions": ["tabs", "notifications", "storage", "identity"], + "host_permissions": [ + "https://newsapi.org/", + "https://betterseqta.org/", + "https://accounts.betterseqta.org/", + "https://www.googleapis.com/", + "https://oauth2.googleapis.com/", + "*://*/*" + ], "background": { "service_worker": "background.ts" }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org https://www.googleapis.com https://oauth2.googleapis.com" }, "content_scripts": [ { "matches": ["*://*/*"], + "exclude_matches": ["*://accounts.betterseqta.org/*"], "js": ["SEQTA.ts"], "run_at": "document_start" } @@ -36,7 +44,8 @@ "resources/icons/*", "resources/update-image.webp", "resources/pdfjs/pdf.worker.min.mjs", - "resources/pdfjs/pdf.legacy.min.mjs" + "resources/pdfjs/pdf.legacy.min.mjs", + "assets/*" ], "matches": ["*://*/*"] } diff --git a/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte b/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte new file mode 100644 index 00000000..4f7990c1 --- /dev/null +++ b/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte @@ -0,0 +1,140 @@ + + +{#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 new file mode 100644 index 00000000..7499b3a2 --- /dev/null +++ b/src/plugins/built-in/timetable/CalendarSyncControl.svelte @@ -0,0 +1,705 @@ + + +
+ + + {#if menuOpen} + + {/if} + + { + if (busy !== "disconnect") showDisconnect = false; + }} + onConfirm={confirmDisconnect} + /> + + {#if toast} +
+ {toast.message} +
+ {/if} +
+ + diff --git a/src/plugins/built-in/timetable/calendarSyncHost.css b/src/plugins/built-in/timetable/calendarSyncHost.css new file mode 100644 index 00000000..b91b5d5e --- /dev/null +++ b/src/plugins/built-in/timetable/calendarSyncHost.css @@ -0,0 +1,23 @@ +.timetablepage #toolbar { + position: relative; + z-index: 100; +} + +.timetable-calendar-controls { + position: relative; + z-index: 100001; + display: inline-flex; + align-items: center; +} + +.timetable-calendar-controls.bsplus-cal-menu-open { + z-index: 2147483646; +} + +.timetablepage #toolbar:has(.bsplus-cal-menu-open) { + z-index: 2147483646 !important; +} + +.bsplus-calendar-sync-mount { + display: inline-flex; +} diff --git a/src/plugins/built-in/timetable/calendarSyncTheme.ts b/src/plugins/built-in/timetable/calendarSyncTheme.ts new file mode 100644 index 00000000..357ff638 --- /dev/null +++ b/src/plugins/built-in/timetable/calendarSyncTheme.ts @@ -0,0 +1,82 @@ +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; +import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor"; + +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(); + + 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 new file mode 100644 index 00000000..a1059bb4 --- /dev/null +++ b/src/plugins/built-in/timetable/calendarSyncUi.ts @@ -0,0 +1,53 @@ +import { mount, unmount } from "svelte"; +import CalendarSyncControl from "./CalendarSyncControl.svelte"; +import { syncCalendarSyncTheme } from "./calendarSyncTheme"; +import hostStyles from "./calendarSyncHost.css?inline"; + +const CONTROLS_CLASS = "timetable-calendar-controls"; +const HOST_STYLE_ID = "bsplus-calendar-sync-host-styles"; + +let currentApp: ReturnType | null = null; +let mountRoot: HTMLElement | null = null; + +function ensureHostStyles() { + if (document.getElementById(HOST_STYLE_ID)) return; + const style = document.createElement("style"); + style.id = HOST_STYLE_ID; + style.textContent = hostStyles; + document.head.appendChild(style); +} + +function teardown() { + if (currentApp) { + unmount(currentApp); + currentApp = null; + } + + mountRoot = null; + document.querySelector(`.${CONTROLS_CLASS}`)?.remove(); + document.getElementById(HOST_STYLE_ID)?.remove(); +} + +export async function mountGoogleCalendarButton(): Promise { + if (document.querySelector(`.${CONTROLS_CLASS}`)) return; + + const toolbar = document.getElementById("toolbar"); + if (!toolbar) return; + + ensureHostStyles(); + + const controls = document.createElement("div"); + controls.className = `${CONTROLS_CLASS} bsplus-timetable-control`; + toolbar.appendChild(controls); + + mountRoot = document.createElement("div"); + mountRoot.className = "bsplus-calendar-sync-mount"; + syncCalendarSyncTheme(mountRoot); + controls.appendChild(mountRoot); + + currentApp = mount(CalendarSyncControl, { target: mountRoot }); +} + +export function unmountGoogleCalendarButton(): void { + teardown(); +} diff --git a/src/plugins/built-in/timetable/index.ts b/src/plugins/built-in/timetable/index.ts index fb135116..1bdc3cb9 100644 --- a/src/plugins/built-in/timetable/index.ts +++ b/src/plugins/built-in/timetable/index.ts @@ -2,6 +2,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import type { Plugin } from "../../core/types"; import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat"; import { waitForElm } from "@/seqta/utils/waitForElm"; +import { mountGoogleCalendarButton, unmountGoogleCalendarButton } from "./calendarSyncUi"; const timetablePlugin: Plugin<{}, {}> = { id: "timetable", @@ -26,6 +27,7 @@ const timetablePlugin: Plugin<{}, {}> = { const hideControls = document.querySelector(".timetable-hide-controls"); if (hideControls) hideControls.remove(); + unmountGoogleCalendarButton(); resetTimetableStyles(); } }; @@ -81,6 +83,7 @@ async function handleTimetable(): Promise { handleTimetableZoom(); handleTimetableAssessmentHide(); + void mountGoogleCalendarButton(); } function handleTimetableZoom(): void { diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 6e150cb8..165a3f88 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -35,6 +35,7 @@ import { observeMenuItemPosition } from "@/seqta/utils/sidebarMenuIcons"; // Icons and fonts import IconFamily from "@/resources/fonts/IconFamily.woff"; +import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; // Stylesheets import iframeCSS from "@/css/iframe.scss?raw"; @@ -106,7 +107,7 @@ export async function finishLoad() { } export function GetCSSElement(file: string) { - const cssFile = browser.runtime.getURL(file); + const cssFile = resolveExtensionAssetUrl(file); const fileref = document.createElement("link"); fileref.setAttribute("rel", "stylesheet"); fileref.setAttribute("type", "text/css"); @@ -814,7 +815,7 @@ function InjectCustomIcons() { style.innerHTML = ` @font-face { font-family: 'IconFamily'; - src: url('${browser.runtime.getURL(IconFamily)}') format('woff'); + src: url('${resolveExtensionAssetUrl(IconFamily)}') format('woff'); font-weight: normal; font-style: normal; }`; diff --git a/src/seqta/utils/Loaders/LoadEngageHomePage.ts b/src/seqta/utils/Loaders/LoadEngageHomePage.ts index e4c57b5c..647cd881 100644 --- a/src/seqta/utils/Loaders/LoadEngageHomePage.ts +++ b/src/seqta/utils/Loaders/LoadEngageHomePage.ts @@ -1,6 +1,7 @@ import { animate } from "motion"; import browser from "webextension-polyfill"; import LogoLight from "@/resources/icons/betterseqta-light-icon.png"; +import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour"; import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat"; import debounce from "@/seqta/utils/debounce"; @@ -129,7 +130,7 @@ function renderEngageDayLessons(): void { if (lessons.length === 0) { dayContainer.innerHTML = `
- +

No lessons for this day.

`; } else { @@ -714,7 +715,7 @@ function showEngageTimetableError(message: string): void { dayContainer.classList.remove("loading"); dayContainer.innerHTML = `
- +

${message}

`; } @@ -725,7 +726,7 @@ function showEngageNoticesSectionError(message: string): void { noticeContainer.classList.remove("loading"); noticeContainer.innerHTML = `
- +

${message}

`; } diff --git a/src/seqta/utils/Loaders/LoadHomePage.ts b/src/seqta/utils/Loaders/LoadHomePage.ts index dcbd553d..13e73066 100644 --- a/src/seqta/utils/Loaders/LoadHomePage.ts +++ b/src/seqta/utils/Loaders/LoadHomePage.ts @@ -1,6 +1,7 @@ import { animate, stagger } from "motion"; import browser from "webextension-polyfill"; import LogoLight from "@/resources/icons/betterseqta-light-icon.png"; +import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import assessmentsicon from "@/seqta/icons/assessmentsIcon"; import coursesicon from "@/seqta/icons/coursesIcon"; import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour"; @@ -726,7 +727,7 @@ function callHomeTimetable(date: string, change?: any) { const dummyDay = document.createElement("div"); dummyDay.classList.add("day-empty"); const img = document.createElement("img"); - img.src = browser.runtime.getURL(LogoLight); + img.src = resolveExtensionAssetUrl(LogoLight); const text = document.createElement("p"); text.innerText = "No lessons available."; dummyDay.append(img, text); @@ -978,7 +979,7 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) { if (assessments.length === 0) { upcomingitemcontainer!.innerHTML = `
- +

No assessments available.

`; } diff --git a/src/seqta/utils/SendNewsPage.ts b/src/seqta/utils/SendNewsPage.ts index 31369ebc..d97667d4 100644 --- a/src/seqta/utils/SendNewsPage.ts +++ b/src/seqta/utils/SendNewsPage.ts @@ -4,6 +4,7 @@ import { delay } from "./delay"; import { settingsState } from "./listeners/SettingsState"; import browser from "webextension-polyfill"; import LogoLightOutline from "@/resources/icons/betterseqta-light-outline.png"; +import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import { animate, stagger } from "motion"; export async function SendNewsPage() { @@ -58,7 +59,7 @@ export async function SendNewsPage() { const emptyState = document.createElement("div"); emptyState.classList.add("day-empty"); const img = document.createElement("img"); - img.src = browser.runtime.getURL(LogoLightOutline); + img.src = resolveExtensionAssetUrl(LogoLightOutline); const text = document.createElement("p"); text.innerText = "No news articles available right now."; emptyState.append(img, text); @@ -79,7 +80,7 @@ export async function SendNewsPage() { if (article.urlToImage == "null" || article.urlToImage == null) { articleimage.style.cssText = ` - background-image: url(${browser.runtime.getURL(LogoLightOutline)}); + background-image: url(${resolveExtensionAssetUrl(LogoLightOutline)}); width: 20%; margin: 0 7.5%; `; diff --git a/src/seqta/utils/cloudSettingsSync.ts b/src/seqta/utils/cloudSettingsSync.ts index f5337bf5..745ac455 100644 --- a/src/seqta/utils/cloudSettingsSync.ts +++ b/src/seqta/utils/cloudSettingsSync.ts @@ -37,6 +37,7 @@ export const KEYS_OMITTED_FROM_CLOUD_UPLOAD = [ "bsplus_user", "cloudAccessToken", "cloudUsername", + "bsplus_google_calendar", ] as const; /** @@ -67,6 +68,7 @@ const AUTH_KEYS_TO_PRESERVE = [ "bsplus_refresh_token", "bsplus_client_id", "bsplus_user", + "bsplus_google_calendar", ] as const; const OMIT_FROM_UPLOAD_EXACT = new Set([ diff --git a/src/seqta/utils/googleCalendar/accountsToken.ts b/src/seqta/utils/googleCalendar/accountsToken.ts new file mode 100644 index 00000000..8aad0c06 --- /dev/null +++ b/src/seqta/utils/googleCalendar/accountsToken.ts @@ -0,0 +1,76 @@ +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/eventMapper.test.ts b/src/seqta/utils/googleCalendar/eventMapper.test.ts new file mode 100644 index 00000000..f6e2ef94 --- /dev/null +++ b/src/seqta/utils/googleCalendar/eventMapper.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "@jest/globals"; +import { + lessonToGoogleEvent, + mapLessonsToGoogleEvents, + seqtaLessonKey, + shouldSyncLesson, +} from "./eventMapper"; +import type { SeqtaTimetableLesson } from "./types"; + +const ORIGIN = "https://school.seqta.com.au"; + +const baseLesson: SeqtaTimetableLesson = { + date: "2026-06-27", + from: "09:00:00", + until: "10:00:00", + description: "10 Mathematics", + staff: "Mr Smith", + room: "MA1", + code: "10MAT", + type: "class", + calendarid: 12345, +}; + +describe("shouldSyncLesson", () => { + it("accepts normal class rows", () => { + expect(shouldSyncLesson(baseLesson)).toBe(true); + }); + + it("rejects holidays and rows without times", () => { + expect(shouldSyncLesson({ ...baseLesson, type: "holiday" })).toBe(false); + expect(shouldSyncLesson({ ...baseLesson, from: "" })).toBe(false); + }); +}); + +describe("seqtaLessonKey", () => { + it("prefers calendarid when present", () => { + expect(seqtaLessonKey(ORIGIN, baseLesson)).toBe(`${ORIGIN}:cal:12345`); + }); +}); + +describe("lessonToGoogleEvent", () => { + it("maps SEQTA lesson fields to Google event input", () => { + const event = lessonToGoogleEvent(ORIGIN, baseLesson, "Australia/Perth"); + expect(event).toMatchObject({ + summary: "10 Mathematics", + location: "MA1", + startDateTime: "2026-06-27T09:00:00", + endDateTime: "2026-06-27T10:00:00", + timeZone: "Australia/Perth", + }); + expect(event?.description).toContain("Mr Smith"); + }); +}); + +describe("mapLessonsToGoogleEvents", () => { + it("deduplicates by seqta key", () => { + const events = mapLessonsToGoogleEvents( + ORIGIN, + [baseLesson, { ...baseLesson }], + "Australia/Perth", + ); + expect(events).toHaveLength(1); + }); +}); diff --git a/src/seqta/utils/googleCalendar/eventMapper.ts b/src/seqta/utils/googleCalendar/eventMapper.ts new file mode 100644 index 00000000..5d708fc4 --- /dev/null +++ b/src/seqta/utils/googleCalendar/eventMapper.ts @@ -0,0 +1,95 @@ +import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar"; +import type { GoogleCalendarEventInput, SeqtaTimetableLesson } from "./types"; + +const SKIP_TYPES = new Set(["note", "holiday", "assembly-note"]); + +function normalizeTime(value: string): string { + const trimmed = value.trim(); + if (/^\d{1,2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed.slice(0, 5); + if (/^\d{1,2}:\d{2}$/.test(trimmed)) return trimmed; + return trimmed; +} + +export function seqtaLessonKey(origin: string, lesson: SeqtaTimetableLesson): string { + if (lesson.calendarid != null && String(lesson.calendarid).length > 0) { + return `${origin}:cal:${lesson.calendarid}`; + } + if (lesson.ci != null) { + return `${origin}:ci:${lesson.ci}:${lesson.date}:${normalizeTime(lesson.from)}`; + } + return [ + origin, + lesson.date, + normalizeTime(lesson.from), + lesson.code ?? "", + lesson.description ?? "", + ].join(":"); +} + +export function shouldSyncLesson(lesson: SeqtaTimetableLesson): boolean { + if (!lesson.date || !lesson.from || !lesson.until) return false; + if (lesson.type && SKIP_TYPES.has(lesson.type.toLowerCase())) return false; + const title = (lesson.description ?? lesson.code ?? "").trim(); + if (!title) return false; + return true; +} + +export function lessonToGoogleEvent( + origin: string, + lesson: SeqtaTimetableLesson, + timeZone: string, +): GoogleCalendarEventInput | null { + if (!shouldSyncLesson(lesson)) return null; + + const from = normalizeTime(lesson.from); + const until = normalizeTime(lesson.until); + const summary = (lesson.description ?? lesson.code ?? "Class").trim(); + const staff = lesson.staff?.trim(); + const room = lesson.room?.trim(); + + const descriptionLines = ["Synced by BetterSEQTA+"]; + if (staff) descriptionLines.push(`Teacher: ${staff}`); + if (lesson.code) descriptionLines.push(`Code: ${lesson.code}`); + if (lesson.period) descriptionLines.push(`Period: ${lesson.period}`); + + return { + seqtaKey: seqtaLessonKey(origin, lesson), + summary, + location: room || undefined, + description: descriptionLines.join("\n"), + startDateTime: `${lesson.date}T${from}:00`, + endDateTime: `${lesson.date}T${until}:00`, + timeZone, + }; +} + +export function mapLessonsToGoogleEvents( + origin: string, + lessons: SeqtaTimetableLesson[], + timeZone: string, +): GoogleCalendarEventInput[] { + const out: GoogleCalendarEventInput[] = []; + const seen = new Set(); + for (const lesson of lessons) { + const mapped = lessonToGoogleEvent(origin, lesson, timeZone); + if (!mapped || seen.has(mapped.seqtaKey)) continue; + seen.add(mapped.seqtaKey); + out.push(mapped); + } + return out; +} + +export function googleApiEventBody(event: GoogleCalendarEventInput): Record { + return { + summary: event.summary, + location: event.location, + description: event.description, + start: { dateTime: event.startDateTime, timeZone: event.timeZone }, + end: { dateTime: event.endDateTime, timeZone: event.timeZone }, + extendedProperties: { + private: { + [BSPLUS_GOOGLE_CALENDAR_EVENT_PROP]: event.seqtaKey, + }, + }, + }; +} diff --git a/src/seqta/utils/googleCalendar/fetchTimetable.ts b/src/seqta/utils/googleCalendar/fetchTimetable.ts new file mode 100644 index 00000000..25c97c3e --- /dev/null +++ b/src/seqta/utils/googleCalendar/fetchTimetable.ts @@ -0,0 +1,82 @@ +import { GOOGLE_CALENDAR_SYNC_WEEKS } from "@/config/googleCalendar"; +import { toISODate, weekRangeContaining } from "@/seqta/utils/Loaders/engageParentTimetable"; +import type { SeqtaTimetableLesson } from "./types"; + +export function syncDateRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS): { + from: string; + until: string; +} { + const { from } = weekRangeContaining(new Date()); + const end = new Date(from + "T12:00:00"); + end.setDate(end.getDate() + weeksAhead * 7 - 1); + return { from, until: toISODate(end) }; +} + +async function postSeqtaJson(path: string, body: Record): Promise { + const res = await fetch(`${location.origin}${path}`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json; charset=utf-8", + "X-Requested-With": "XMLHttpRequest", + Accept: "text/javascript, text/html, application/xml, text/xml, */*", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`SEQTA request failed (${res.status})`); + } + return (await res.json()) as T; +} + +export async function resolveStudentId(): Promise { + try { + const json = await postSeqtaJson<{ payload?: { id?: number; student?: number } }>( + "/seqta/student/login", + { mode: "normal", query: null, redirect_url: location.origin }, + ); + const id = json?.payload?.id ?? json?.payload?.student; + return typeof id === "number" && Number.isFinite(id) ? id : undefined; + } catch { + return undefined; + } +} + +function isEngageParentContext(): boolean { + return ( + location.pathname.includes("/parent/") || + location.hash.includes("/parent/") || + document.body.classList.contains("parent") + ); +} + +export async function fetchTimetableForSync(): Promise { + const { from, until } = syncDateRange(); + + if (isEngageParentContext()) { + const listJson = await postSeqtaJson<{ payload?: { id?: string | number }[] }>( + "/seqta/parent/load/timetable", + { list: true }, + ); + const firstChild = Array.isArray(listJson?.payload) ? listJson.payload[0] : undefined; + const studentId = firstChild?.id; + if (studentId == null) { + throw new Error("No student found on this parent account."); + } + const data = await postSeqtaJson<{ payload?: { items?: SeqtaTimetableLesson[] } }>( + "/seqta/parent/load/timetable", + { from, until, student: studentId }, + ); + return Array.isArray(data?.payload?.items) ? data.payload.items : []; + } + + const studentId = await resolveStudentId(); + const body: Record = { from, until }; + if (studentId != null) body.student = studentId; + + const data = await postSeqtaJson<{ payload?: { items?: SeqtaTimetableLesson[] } }>( + "/seqta/student/load/timetable?", + body, + ); + return Array.isArray(data?.payload?.items) ? data.payload.items : []; +} diff --git a/src/seqta/utils/googleCalendar/storage.ts b/src/seqta/utils/googleCalendar/storage.ts new file mode 100644 index 00000000..1985d363 --- /dev/null +++ b/src/seqta/utils/googleCalendar/storage.ts @@ -0,0 +1,39 @@ +import browser from "webextension-polyfill"; + +/** 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; + lastSyncOrigin?: string; + /** `${origin}::${seqtaKey}` → Google Calendar event id */ + 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}`; +} diff --git a/src/seqta/utils/googleCalendar/syncEngine.ts b/src/seqta/utils/googleCalendar/syncEngine.ts new file mode 100644 index 00000000..9ee4045b --- /dev/null +++ b/src/seqta/utils/googleCalendar/syncEngine.ts @@ -0,0 +1,100 @@ +import { verboseLog } from "@/utils/verboseLog"; +import { isGoogleCalendarConfigured } from "@/config/googleCalendar"; +import { googleApiEventBody, mapLessonsToGoogleEvents } from "@/seqta/utils/googleCalendar/eventMapper"; +import { + eventMapKey, + readGoogleCalendarState, + writeGoogleCalendarState, +} from "@/seqta/utils/googleCalendar/storage"; +import type { GoogleCalendarSyncRequest, GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; +import { upsertGoogleCalendarEvent } from "@/seqta/utils/googleCalendar/upsertEvent"; + +const EVENT_MAP_PERSIST_EVERY = 10; + +/** 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, +): 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 timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const events = mapLessonsToGoogleEvents(request.origin, request.lessons, timeZone); + if (events.length === 0) { + return { + success: false, + configured: true, + connected: true, + error: "No timetable classes found to sync for the selected range.", + }; + } + + let accessToken = await getAccessToken(); + const calendarId = "primary"; + const eventMap = { ...(state.eventMap ?? {}) }; + + let created = 0; + let updated = 0; + let failed = 0; + const lastSyncAt = Date.now(); + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + const mapKey = eventMapKey(request.origin, event.seqtaKey); + const existingId = eventMap[mapKey]; + try { + const googleId = await upsertGoogleCalendarEvent( + accessToken, + calendarId, + existingId, + googleApiEventBody(event), + async () => { + accessToken = await getAccessToken(); + return accessToken; + }, + ); + if (existingId) updated += 1; + else created += 1; + eventMap[mapKey] = googleId; + + if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) { + await writeGoogleCalendarState({ + eventMap, + lastSyncAt, + lastSyncOrigin: request.origin, + }); + } + } catch (err) { + verboseLog("[BetterSEQTA+] Google Calendar event sync failed:", err); + failed += 1; + } + } + + const syncResult: GoogleCalendarSyncResult = { + success: failed === 0, + configured: true, + connected: true, + created, + updated, + skipped: 0, + failed, + lastSyncAt, + error: + failed > 0 + ? `Synced with ${failed} error${failed === 1 ? "" : "s"}. Check the console for details.` + : undefined, + }; + + return syncResult; +} diff --git a/src/seqta/utils/googleCalendar/types.ts b/src/seqta/utils/googleCalendar/types.ts new file mode 100644 index 00000000..cd645a3d --- /dev/null +++ b/src/seqta/utils/googleCalendar/types.ts @@ -0,0 +1,47 @@ +export interface SeqtaTimetableLesson { + date: string; + from: string; + until: string; + description: string; + staff?: string; + room?: string; + code?: string; + type?: string; + period?: string; + calendarid?: string | number; + ci?: number; +} + +export interface GoogleCalendarEventInput { + seqtaKey: string; + summary: string; + location?: string; + description?: string; + startDateTime: string; + endDateTime: string; + timeZone: string; +} + +export interface GoogleCalendarSyncRequest { + origin: string; + lessons: SeqtaTimetableLesson[]; +} + +export interface GoogleCalendarSyncResult { + success: boolean; + connected?: boolean; + configured?: boolean; + created?: number; + updated?: number; + skipped?: number; + failed?: number; + lastSyncAt?: number; + error?: string; +} + +export interface GoogleCalendarStatus { + configured: boolean; + connected: boolean; + lastSyncAt?: number; + lastSyncOrigin?: string; +} diff --git a/src/seqta/utils/googleCalendar/upsertEvent.ts b/src/seqta/utils/googleCalendar/upsertEvent.ts new file mode 100644 index 00000000..7737ee3c --- /dev/null +++ b/src/seqta/utils/googleCalendar/upsertEvent.ts @@ -0,0 +1,44 @@ +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; +} diff --git a/vite.config.ts b/vite.config.ts index 27dd5fec..c85800cd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import { join, resolve } from "path"; import touchGlobalCSSPlugin from "./lib/touchGlobalCSS"; @@ -59,7 +59,13 @@ const mode = process.env.MODE || "chrome"; // Check the environment variable to /** Million's compiler can emit `new Function()`, which Firefox extension pages block (strict CSP, no unsafe-eval). */ const useMillion = mode.toLowerCase() !== "firefox"; -export default defineConfig(({ command }) => ({ +const repoRoot = __dirname; + +export default defineConfig(({ command, mode: viteMode }) => { + // `.env` lives at repo root (not `src/`). Required for `npm run dev` and builds. + const env = loadEnv(viteMode, repoRoot, ""); + + return { // Content scripts run on the host page; absolute `/assets/...` URLs would // resolve against SEQTA instead of chrome-extension://. Relative base makes // Vite emit import.meta.url-relative chunk/CSS URLs at runtime. @@ -73,7 +79,9 @@ export default defineConfig(({ command }) => ({ ), __UPDATE_CHANNEL__: JSON.stringify(process.env.UPDATE_CHANNEL ?? "stable"), __BUILD_LABEL__: JSON.stringify(process.env.BUILD_LABEL ?? ""), + __GOOGLE_OAUTH_CLIENT_ID__: JSON.stringify(env.GOOGLE_OAUTH_CLIENT_ID ?? ""), }, + envDir: repoRoot, plugins: [ base64Loader, InlineWorkerPlugin(), @@ -145,4 +153,5 @@ export default defineConfig(({ command }) => ({ }, }, }, -})); +}; +}); From fe1c2f0250c2fbc4b99096668eb0e17658aaf372 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sat, 27 Jun 2026 12:03:56 +0930 Subject: [PATCH 2/6] fix: add missing parseCssColor and verboseLog modules for calendar sync build Co-authored-by: Cursor --- src/seqta/ui/colors/parseCssColor.test.ts | 46 ++++++++++++++++ src/seqta/ui/colors/parseCssColor.ts | 65 +++++++++++++++++++++++ src/utils/verboseLog.ts | 38 +++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 src/seqta/ui/colors/parseCssColor.test.ts create mode 100644 src/seqta/ui/colors/parseCssColor.ts create mode 100644 src/utils/verboseLog.ts diff --git a/src/seqta/ui/colors/parseCssColor.test.ts b/src/seqta/ui/colors/parseCssColor.test.ts new file mode 100644 index 00000000..ae0dbc0b --- /dev/null +++ b/src/seqta/ui/colors/parseCssColor.test.ts @@ -0,0 +1,46 @@ +import { + extractSolidColor, + normalizeCssColorString, + parseCssColor, +} from "./parseCssColor"; + +describe("normalizeCssColorString", () => { + it("lowercases uppercase RGBA/RGB function names", () => { + expect(normalizeCssColorString("RGBA(3, 29, 11, 0.58)")).toBe( + "rgba(3, 29, 11, 0.58)", + ); + expect(normalizeCssColorString("RGB(10, 20, 30)")).toBe("rgb(10, 20, 30)"); + }); +}); + +describe("extractSolidColor", () => { + it("extracts solid uppercase RGBA values", () => { + expect(extractSolidColor("RGBA(3, 29, 11, 0.58)")).toBe( + "rgba(3, 29, 11, 0.58)", + ); + }); + + it("extracts the first rgba stop from gradients with mixed casing", () => { + expect( + extractSolidColor( + "linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)", + ), + ).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 new file mode 100644 index 00000000..d2835f94 --- /dev/null +++ b/src/seqta/ui/colors/parseCssColor.ts @@ -0,0 +1,65 @@ +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. + */ +export function normalizeCssColorString(value: string): string { + return value + .trim() + .replace(/\bRGBA?\(/gi, (match) => match.toLowerCase()) + .replace(/\bHSLA?\(/gi, (match) => match.toLowerCase()); +} + +/** Pick a single solid colour from a CSS value (hex, rgb(a), hsl(a), or gradient). */ +export function extractSolidColor(value: string): string | null { + const trimmed = normalizeCssColorString(value); + if (!trimmed || trimmed === "initial") return null; + if ( + trimmed.startsWith("#") || + /^rgba?\(/i.test(trimmed) || + /^hsla?\(/i.test(trimmed) + ) { + return trimmed; + } + if (trimmed.includes("gradient")) { + const match = trimmed.match( + /#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/gi, + ); + return match?.[0] ? normalizeCssColorString(match[0]) : 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/utils/verboseLog.ts b/src/utils/verboseLog.ts new file mode 100644 index 00000000..9d77f3f4 --- /dev/null +++ b/src/utils/verboseLog.ts @@ -0,0 +1,38 @@ +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); +} From eae2e0587cf3c3f99352f9110ec45f4cc173440a Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sat, 27 Jun 2026 14:19:28 +0930 Subject: [PATCH 3/6] feat: tweak calendar and clean it up --- src/background.ts | 3 +- src/background/googleCalendar.ts | 64 ++++ src/background/googleCalendarWeekly.ts | 74 ++++ src/config/googleCalendar.ts | 5 +- src/manifests/manifest.json | 2 +- .../CalendarDeleteEventsModal.svelte | 140 ++++++++ .../timetable/CalendarSyncControl.svelte | 320 ++++++++++++------ .../timetable/CalendarSyncProgress.svelte | 67 ++++ .../built-in/timetable/calendarSyncUi.ts | 2 + .../googleCalendar/calendarSyncListener.ts | 47 +++ .../utils/googleCalendar/eventMapEntry.ts | 29 ++ .../utils/googleCalendar/fetchTimetable.ts | 26 +- src/seqta/utils/googleCalendar/storage.ts | 9 +- .../googleCalendar/syncDateRange.test.ts | 31 ++ .../utils/googleCalendar/syncDateRange.ts | 45 +++ .../utils/googleCalendar/syncEngine.test.ts | 139 ++++++++ src/seqta/utils/googleCalendar/syncEngine.ts | 272 ++++++++++++++- src/seqta/utils/googleCalendar/syncRunner.ts | 83 +++++ .../utils/googleCalendar/syncSettings.ts | 57 ++++ src/seqta/utils/googleCalendar/types.ts | 28 ++ src/seqta/utils/googleCalendar/upsertEvent.ts | 19 ++ 21 files changed, 1336 insertions(+), 126 deletions(-) create mode 100644 src/background/googleCalendarWeekly.ts create mode 100644 src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte create mode 100644 src/plugins/built-in/timetable/CalendarSyncProgress.svelte create mode 100644 src/seqta/utils/googleCalendar/calendarSyncListener.ts create mode 100644 src/seqta/utils/googleCalendar/eventMapEntry.ts create mode 100644 src/seqta/utils/googleCalendar/syncDateRange.test.ts create mode 100644 src/seqta/utils/googleCalendar/syncDateRange.ts create mode 100644 src/seqta/utils/googleCalendar/syncEngine.test.ts create mode 100644 src/seqta/utils/googleCalendar/syncRunner.ts create mode 100644 src/seqta/utils/googleCalendar/syncSettings.ts diff --git a/src/background.ts b/src/background.ts index c4b5eda4..0ca8b442 100644 --- a/src/background.ts +++ b/src/background.ts @@ -13,7 +13,7 @@ import { withSuppressedCloudAutoUpload, } from "./background/cloudSettingsAutoSync"; import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl"; -import { registerGoogleCalendarMessageHandlers } from "./background/googleCalendar"; +import { registerGoogleCalendarMessageHandlers, initGoogleCalendarBackground } from "./background/googleCalendar"; /** * Session-only dev-mode override of the content API base. @@ -559,6 +559,7 @@ const MESSAGE_HANDLERS: Record = { }; registerGoogleCalendarMessageHandlers(MESSAGE_HANDLERS, isTrustedSender); +initGoogleCalendarBackground(); browser.runtime.onMessage.addListener( // @ts-ignore - OnMessageListener expects literal true for async, we return boolean diff --git a/src/background/googleCalendar.ts b/src/background/googleCalendar.ts index edec6328..86ccb2f0 100644 --- a/src/background/googleCalendar.ts +++ b/src/background/googleCalendar.ts @@ -16,10 +16,20 @@ import { readGoogleCalendarState, writeGoogleCalendarState, } from "@/seqta/utils/googleCalendar/storage"; +import { + clampSyncWeeks, + getAutoSyncWeekly, + getSyncWeeksAhead, +} from "@/seqta/utils/googleCalendar/syncSettings"; import type { GoogleCalendarStatus, GoogleCalendarSyncResult, } from "@/seqta/utils/googleCalendar/types"; +import { + clearWeeklySyncAlarm, + ensureWeeklySyncAlarm, + registerWeeklySyncAlarmListener, +} from "./googleCalendarWeekly"; function base64UrlEncode(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); @@ -197,6 +207,8 @@ async function connectGoogleCalendar(): Promise { connectedAt: Date.now(), }); + await ensureWeeklySyncAlarm(); + return { success: true, configured: true, connected: true }; } catch (err) { return { @@ -209,11 +221,16 @@ async function connectGoogleCalendar(): Promise { async function getGoogleCalendarStatus(): Promise { const state = await readGoogleCalendarState(); + const syncWeeksAhead = await getSyncWeeksAhead(); + const autoSyncWeekly = await getAutoSyncWeekly(); return { configured: isGoogleCalendarConfigured(), connected: !!(state.refreshToken || state.accessToken), lastSyncAt: state.lastSyncAt, + lastWeeklySyncAt: state.lastWeeklySyncAt, lastSyncOrigin: state.lastSyncOrigin, + syncWeeksAhead, + autoSyncWeekly, }; } @@ -222,6 +239,7 @@ export async function handleGoogleCalendarConnect(): Promise { + await clearWeeklySyncAlarm(); await clearGoogleCalendarState(); return { success: true }; } @@ -298,4 +316,50 @@ export function registerGoogleCalendarMessageHandlers( }); 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 writeGoogleCalendarState(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 function initGoogleCalendarBackground(): void { + registerWeeklySyncAlarmListener(); + void ensureWeeklySyncAlarm(); } diff --git a/src/background/googleCalendarWeekly.ts b/src/background/googleCalendarWeekly.ts new file mode 100644 index 00000000..b1ece4b1 --- /dev/null +++ b/src/background/googleCalendarWeekly.ts @@ -0,0 +1,74 @@ +import browser from "webextension-polyfill"; +import { verboseLog } from "@/utils/verboseLog"; +import { + getAutoSyncWeekly, + GOOGLE_CALENDAR_WEEKLY_ALARM, + markWeeklySyncPending, +} from "@/seqta/utils/googleCalendar/syncSettings"; +import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/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 isCalendarConnected(): Promise { + const state = await readGoogleCalendarState(); + return !!(state.refreshToken || state.accessToken); +} + +export async function ensureWeeklySyncAlarm(): Promise { + const connected = await isCalendarConnected(); + const enabled = await getAutoSyncWeekly(); + if (!connected || !enabled) { + await browser.alarms.clear(GOOGLE_CALENDAR_WEEKLY_ALARM); + return; + } + + const existing = await browser.alarms.get(GOOGLE_CALENDAR_WEEKLY_ALARM); + if (!existing) { + await browser.alarms.create(GOOGLE_CALENDAR_WEEKLY_ALARM, { + periodInMinutes: WEEKLY_PERIOD_MINUTES, + }); + } +} + +export async function clearWeeklySyncAlarm(): Promise { + await browser.alarms.clear(GOOGLE_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: "googleCalendarRunWeeklySync" }); + 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 isCalendarConnected()) || !(await getAutoSyncWeekly())) return; + + const delivered = await triggerWeeklySyncOnSeqtaTabs(); + if (!delivered) { + await markWeeklySyncPending(); + } +} + +export function registerWeeklySyncAlarmListener(): void { + browser.alarms.onAlarm.addListener((alarm) => { + if (alarm.name !== GOOGLE_CALENDAR_WEEKLY_ALARM) return; + void handleWeeklySyncAlarm(); + }); +} diff --git a/src/config/googleCalendar.ts b/src/config/googleCalendar.ts index 8a24bd14..9b669dc7 100644 --- a/src/config/googleCalendar.ts +++ b/src/config/googleCalendar.ts @@ -28,8 +28,11 @@ export const GOOGLE_CALENDAR_API = "https://www.googleapis.com/calendar/v3"; export const BSPLUS_GOOGLE_CALENDAR_EVENT_PROP = "bsplusSeqtaKey"; -/** Weeks of timetable to sync (from start of current week). */ +/** 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; export function isGoogleCalendarConfigured(): boolean { return GOOGLE_OAUTH_CLIENT_ID.length > 0; diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index a96aa207..812f5de8 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -15,7 +15,7 @@ "64": "resources/icons/icon-64.png" } }, - "permissions": ["tabs", "notifications", "storage", "identity"], + "permissions": ["tabs", "notifications", "storage", "identity", "alarms"], "host_permissions": [ "https://newsapi.org/", "https://betterseqta.org/", diff --git a/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte b/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte new file mode 100644 index 00000000..5accdd94 --- /dev/null +++ b/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte @@ -0,0 +1,140 @@ + + +{#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 7499b3a2..a79d99e4 100644 --- a/src/plugins/built-in/timetable/CalendarSyncControl.svelte +++ b/src/plugins/built-in/timetable/CalendarSyncControl.svelte @@ -2,20 +2,38 @@ import { onMount } from "svelte"; import { fade, fly } from "svelte/transition"; import browser from "webextension-polyfill"; - import { fetchTimetableForSync } from "@/seqta/utils/googleCalendar/fetchTimetable"; - import { syncLessonsToGoogleCalendar } from "@/seqta/utils/googleCalendar/syncEngine"; - import type { GoogleCalendarStatus, GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; + import { + GOOGLE_CALENDAR_SYNC_WEEKS_MAX, + GOOGLE_CALENDAR_SYNC_WEEKS_MIN, + } from "@/config/googleCalendar"; + import { maybeRunDueWeeklySync } from "@/seqta/utils/googleCalendar/calendarSyncListener"; + import { deleteSyncedEventsFromGoogleCalendar } from "@/seqta/utils/googleCalendar/syncEngine"; + import { + formatSyncResultMessage, + runGoogleCalendarSync, + } from "@/seqta/utils/googleCalendar/syncRunner"; + import type { + GoogleCalendarStatus, + GoogleCalendarSyncProgress, + GoogleCalendarSyncResult, + } from "@/seqta/utils/googleCalendar/types"; + import CalendarDeleteEventsModal from "./CalendarDeleteEventsModal.svelte"; import CalendarDisconnectModal from "./CalendarDisconnectModal.svelte"; + import CalendarSyncProgress from "./CalendarSyncProgress.svelte"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { syncCalendarSyncTheme } from "./calendarSyncTheme"; - type BusyPhase = "connect" | "sync" | "disconnect" | null; + type BusyPhase = "connect" | "sync" | "delete" | "disconnect" | null; let status = $state({ configured: true, connected: false }); let busy = $state(null); let menuOpen = $state(false); let showDisconnect = $state(false); + let showDeleteEvents = $state(false); let toast = $state<{ message: string; error: boolean } | null>(null); + let syncProgress = $state(null); + let syncWeeksAhead = $state(12); + let autoSyncWeekly = $state(true); let rootEl = $state(null); let triggerEl = $state(null); @@ -38,6 +56,8 @@ status = (await browser.runtime.sendMessage({ type: "googleCalendarStatus", })) as GoogleCalendarStatus; + syncWeeksAhead = status.syncWeeksAhead ?? 12; + autoSyncWeekly = status.autoSyncWeekly !== false; } async function getAccessTokenFromBackground(): Promise { @@ -50,26 +70,42 @@ return res.accessToken; } - async function performSync(): Promise { - const lessons = await fetchTimetableForSync(); - const result = await syncLessonsToGoogleCalendar( - { origin: location.origin, lessons }, - getAccessTokenFromBackground, - ); + function handleSyncProgress(progress: GoogleCalendarSyncProgress) { + syncProgress = progress; + } + + async function saveSyncSettings(patch: { + syncWeeksAhead?: number; + autoSyncWeekly?: boolean; + }) { + const result = (await browser.runtime.sendMessage({ + type: "googleCalendarUpdateSyncSettings", + ...patch, + })) as GoogleCalendarStatus & { success?: boolean }; + if (result.syncWeeksAhead != null) syncWeeksAhead = result.syncWeeksAhead; + if (result.autoSyncWeekly != null) autoSyncWeekly = result.autoSyncWeekly; + status = { ...status, ...result }; + } + + async function performSync(mode: "full" | "incremental" = "full"): Promise { + const result = await runGoogleCalendarSync({ + mode, + onProgress: handleSyncProgress, + }); + + syncProgress = null; if (!result.success) { showToastMessage(result.error ?? "Calendar sync failed.", true); return false; } - const created = result.created ?? 0; - const updated = result.updated ?? 0; status = { ...status, connected: true, lastSyncAt: result.lastSyncAt ?? status.lastSyncAt, }; - showToastMessage(`Google Calendar updated (${created} new, ${updated} updated).`); + showToastMessage(formatSyncResultMessage(result)); return true; } @@ -91,6 +127,7 @@ } catch (err) { showToastMessage(err instanceof Error ? err.message : "Could not connect.", true); } finally { + syncProgress = null; busy = null; } } @@ -108,10 +145,60 @@ } catch (err) { showToastMessage(err instanceof Error ? err.message : "Calendar sync failed.", true); } finally { + syncProgress = null; busy = null; } } + async function confirmDeleteEvents() { + if (isBusy) return; + busy = "delete"; + syncProgress = { + phase: "preparing", + current: 0, + total: 1, + message: "Preparing removal…", + }; + try { + const result = await deleteSyncedEventsFromGoogleCalendar( + location.origin, + getAccessTokenFromBackground, + { onProgress: handleSyncProgress }, + ); + + if (!result.success) { + showToastMessage(result.error ?? "Could not remove calendar events.", true); + return; + } + + const removed = result.deleted ?? 0; + showDeleteEvents = false; + menuOpen = false; + if (removed === 0) { + showToastMessage("No synced events to remove."); + } else { + showToastMessage(`Removed ${removed} event${removed === 1 ? "" : "s"} from Google Calendar.`); + } + } catch (err) { + showToastMessage(err instanceof Error ? err.message : "Remove failed.", true); + } finally { + syncProgress = null; + busy = null; + } + } + + async function onWeeksAheadChange(event: Event) { + const value = Number((event.currentTarget as HTMLInputElement).value); + if (!Number.isFinite(value)) return; + await saveSyncSettings({ syncWeeksAhead: value }); + } + + async function onAutoSyncToggle(event: Event) { + const checked = (event.currentTarget as HTMLInputElement).checked; + autoSyncWeekly = checked; + await saveSyncSettings({ autoSyncWeekly: checked }); + } + async function confirmDisconnect() { if (isBusy) return; busy = "disconnect"; @@ -198,7 +285,12 @@ }); onMount(() => { - void refreshStatus(); + void refreshStatus().then(() => { + void maybeRunDueWeeklySync((message, isError) => { + showToastMessage(message, isError); + void refreshStatus(); + }); + }); const themeKeys = [ "selectedColor", @@ -241,7 +333,7 @@
{#if menuOpen} @@ -332,6 +408,38 @@
+ {#if status.connected} +
+ + +

+ Keeps a rolling {syncWeeksAhead}-week window. Each week adds the next week and removes the oldest. +

+
+ {/if} + + +
{#if !status.connected} +
{/if} diff --git a/src/seqta/utils/googleCalendar/syncEngine.test.ts b/src/seqta/utils/googleCalendar/syncEngine.test.ts index 40eb921b..300dbee8 100644 --- a/src/seqta/utils/googleCalendar/syncEngine.test.ts +++ b/src/seqta/utils/googleCalendar/syncEngine.test.ts @@ -97,6 +97,19 @@ describe("syncLessonsToGoogleCalendar", () => { }); }); + it("does not delete events during incremental sync", async () => { + const result = await syncLessonsToGoogleCalendar( + { origin: ORIGIN, lessons: [baseLesson], mode: "incremental" }, + getAccessToken, + ); + + expect(deleteGoogleCalendarEvent).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + success: true, + deleted: 0, + }); + }); + it("reports progress while syncing", async () => { const progress: Array<{ phase: string; current: number; total: number }> = []; await syncLessonsToGoogleCalendar( diff --git a/src/seqta/utils/googleCalendar/syncEngine.ts b/src/seqta/utils/googleCalendar/syncEngine.ts index 503be74a..bd12e137 100644 --- a/src/seqta/utils/googleCalendar/syncEngine.ts +++ b/src/seqta/utils/googleCalendar/syncEngine.ts @@ -7,7 +7,6 @@ import { normalizeEventMapEntry, } from "@/seqta/utils/googleCalendar/eventMapEntry"; import { - droppedWeekRange, isDateInRange, syncWindowRange, } from "@/seqta/utils/googleCalendar/syncDateRange"; @@ -120,7 +119,6 @@ function entriesToPrune( currentMapKeys: Set, ): Array<[string, string]> { const window = syncWindowRange(weeksAhead); - const dropped = droppedWeekRange(weeksAhead); const prefix = `${origin}::`; const entries: Array<[string, string]> = []; @@ -131,7 +129,7 @@ function entriesToPrune( let shouldDelete = false; if (mode === "incremental") { - shouldDelete = !!entry.date && isDateInRange(entry.date, dropped); + shouldDelete = false; } else if (entry.date) { shouldDelete = !isDateInRange(entry.date, window); } else { From d34333ca387871ac9a09c2d4653ee25f57b711d8 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sat, 27 Jun 2026 15:07:10 +0930 Subject: [PATCH 5/6] feat: support outlook for calendar plus minor fixes --- .env.example | 3 + src/background.ts | 2 + ...gleCalendarWeekly.ts => calendarWeekly.ts} | 40 +- src/background/googleCalendar.ts | 24 +- src/background/outlookCalendar.ts | 302 ++++++++++++++ src/config/outlookCalendar.ts | 43 ++ src/env.d.ts | 1 + src/manifests/manifest.json | 4 +- .../CalendarDeleteEventsModal.svelte | 4 +- .../timetable/CalendarDisconnectModal.svelte | 6 +- .../timetable/CalendarSyncControl.svelte | 371 ++++++++++++------ .../timetable/OutlookCalendarIcon.svelte | 231 +++++++++++ .../built-in/timetable/calendarSyncHost.css | 1 + .../built-in/timetable/calendarSyncTheme.ts | 6 + .../built-in/timetable/calendarSyncUi.ts | 4 +- src/seqta/utils/calendarSync/settings.ts | 65 +++ .../utils/calendarSync/sharedSettings.ts | 48 +++ .../googleCalendar/calendarSyncListener.ts | 89 ++++- .../utils/googleCalendar/syncEngine.test.ts | 2 +- src/seqta/utils/googleCalendar/syncEngine.ts | 2 +- src/seqta/utils/googleCalendar/syncRunner.ts | 9 +- .../utils/googleCalendar/syncSettings.ts | 67 +--- .../utils/outlookCalendar/accountsToken.ts | 77 ++++ .../utils/outlookCalendar/eventMapper.ts | 36 ++ src/seqta/utils/outlookCalendar/storage.ts | 38 ++ src/seqta/utils/outlookCalendar/syncEngine.ts | 348 ++++++++++++++++ src/seqta/utils/outlookCalendar/syncRunner.ts | 76 ++++ src/seqta/utils/outlookCalendar/types.ts | 6 + .../utils/outlookCalendar/upsertEvent.test.ts | 48 +++ .../utils/outlookCalendar/upsertEvent.ts | 68 ++++ vite.config.ts | 1 + 31 files changed, 1791 insertions(+), 231 deletions(-) rename src/background/{googleCalendarWeekly.ts => calendarWeekly.ts} (59%) create mode 100644 src/background/outlookCalendar.ts create mode 100644 src/config/outlookCalendar.ts create mode 100644 src/plugins/built-in/timetable/OutlookCalendarIcon.svelte create mode 100644 src/seqta/utils/calendarSync/settings.ts create mode 100644 src/seqta/utils/calendarSync/sharedSettings.ts create mode 100644 src/seqta/utils/outlookCalendar/accountsToken.ts create mode 100644 src/seqta/utils/outlookCalendar/eventMapper.ts create mode 100644 src/seqta/utils/outlookCalendar/storage.ts create mode 100644 src/seqta/utils/outlookCalendar/syncEngine.ts create mode 100644 src/seqta/utils/outlookCalendar/syncRunner.ts create mode 100644 src/seqta/utils/outlookCalendar/types.ts create mode 100644 src/seqta/utils/outlookCalendar/upsertEvent.test.ts create mode 100644 src/seqta/utils/outlookCalendar/upsertEvent.ts diff --git a/.env.example b/.env.example index ba69324b..ded48c13 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,6 @@ # See docs/GOOGLE_CALENDAR_ACCOUNTS_CALLBACK.md # 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/src/background.ts b/src/background.ts index 0ca8b442..67af512a 100644 --- a/src/background.ts +++ b/src/background.ts @@ -14,6 +14,7 @@ import { } from "./background/cloudSettingsAutoSync"; import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl"; import { registerGoogleCalendarMessageHandlers, initGoogleCalendarBackground } from "./background/googleCalendar"; +import { registerOutlookCalendarMessageHandlers } from "./background/outlookCalendar"; /** * Session-only dev-mode override of the content API base. @@ -559,6 +560,7 @@ const MESSAGE_HANDLERS: Record = { }; registerGoogleCalendarMessageHandlers(MESSAGE_HANDLERS, isTrustedSender); +registerOutlookCalendarMessageHandlers(MESSAGE_HANDLERS, isTrustedSender); initGoogleCalendarBackground(); browser.runtime.onMessage.addListener( diff --git a/src/background/googleCalendarWeekly.ts b/src/background/calendarWeekly.ts similarity index 59% rename from src/background/googleCalendarWeekly.ts rename to src/background/calendarWeekly.ts index b1ece4b1..9a7d2090 100644 --- a/src/background/googleCalendarWeekly.ts +++ b/src/background/calendarWeekly.ts @@ -1,11 +1,12 @@ import browser from "webextension-polyfill"; import { verboseLog } from "@/utils/verboseLog"; import { + CALENDAR_WEEKLY_ALARM, getAutoSyncWeekly, - GOOGLE_CALENDAR_WEEKLY_ALARM, markWeeklySyncPending, -} from "@/seqta/utils/googleCalendar/syncSettings"; +} 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; @@ -14,29 +15,37 @@ function isSeqtaTab(tab: browser.Tabs.Tab): boolean { return title.includes("SEQTA Learn") || title.includes("SEQTA Engage"); } -async function isCalendarConnected(): Promise { - const state = await readGoogleCalendarState(); - return !!(state.refreshToken || state.accessToken); +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 isCalendarConnected(); + const connected = await isAnyCalendarConnected(); const enabled = await getAutoSyncWeekly(); if (!connected || !enabled) { - await browser.alarms.clear(GOOGLE_CALENDAR_WEEKLY_ALARM); + await browser.alarms.clear(CALENDAR_WEEKLY_ALARM); return; } - const existing = await browser.alarms.get(GOOGLE_CALENDAR_WEEKLY_ALARM); + const existing = await browser.alarms.get(CALENDAR_WEEKLY_ALARM); if (!existing) { - await browser.alarms.create(GOOGLE_CALENDAR_WEEKLY_ALARM, { + await browser.alarms.create(CALENDAR_WEEKLY_ALARM, { periodInMinutes: WEEKLY_PERIOD_MINUTES, }); } } export async function clearWeeklySyncAlarm(): Promise { - await browser.alarms.clear(GOOGLE_CALENDAR_WEEKLY_ALARM); + await browser.alarms.clear(CALENDAR_WEEKLY_ALARM); } export async function triggerWeeklySyncOnSeqtaTabs(): Promise { @@ -48,7 +57,7 @@ export async function triggerWeeklySyncOnSeqtaTabs(): Promise { for (const tab of seqtaTabs) { if (tab.id == null) continue; try { - await browser.tabs.sendMessage(tab.id, { type: "googleCalendarRunWeeklySync" }); + await browser.tabs.sendMessage(tab.id, { type: "calendarRunWeeklySync" }); delivered = true; } catch (err) { verboseLog("[BetterSEQTA+] Weekly calendar sync message failed for tab:", tab.id, err); @@ -58,7 +67,7 @@ export async function triggerWeeklySyncOnSeqtaTabs(): Promise { } export async function handleWeeklySyncAlarm(): Promise { - if (!(await isCalendarConnected()) || !(await getAutoSyncWeekly())) return; + if (!(await isAnyCalendarConnected()) || !(await getAutoSyncWeekly())) return; const delivered = await triggerWeeklySyncOnSeqtaTabs(); if (!delivered) { @@ -68,7 +77,12 @@ export async function handleWeeklySyncAlarm(): Promise { export function registerWeeklySyncAlarmListener(): void { browser.alarms.onAlarm.addListener((alarm) => { - if (alarm.name !== GOOGLE_CALENDAR_WEEKLY_ALARM) return; + 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 index 86ccb2f0..9042e855 100644 --- a/src/background/googleCalendar.ts +++ b/src/background/googleCalendar.ts @@ -20,16 +20,16 @@ import { clampSyncWeeks, getAutoSyncWeekly, getSyncWeeksAhead, -} from "@/seqta/utils/googleCalendar/syncSettings"; +} from "@/seqta/utils/calendarSync/settings"; +import { + readSharedCalendarSyncSettings, + writeSharedCalendarSyncSettings, +} from "@/seqta/utils/calendarSync/sharedSettings"; import type { GoogleCalendarStatus, GoogleCalendarSyncResult, } from "@/seqta/utils/googleCalendar/types"; -import { - clearWeeklySyncAlarm, - ensureWeeklySyncAlarm, - registerWeeklySyncAlarmListener, -} from "./googleCalendarWeekly"; +import { ensureWeeklySyncAlarm, initCalendarBackground } from "./calendarWeekly"; function base64UrlEncode(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); @@ -221,13 +221,14 @@ async function connectGoogleCalendar(): Promise { 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: state.lastWeeklySyncAt, + lastWeeklySyncAt: shared.lastWeeklySyncAt, lastSyncOrigin: state.lastSyncOrigin, syncWeeksAhead, autoSyncWeekly, @@ -239,8 +240,8 @@ export async function handleGoogleCalendarConnect(): Promise { - await clearWeeklySyncAlarm(); await clearGoogleCalendarState(); + await ensureWeeklySyncAlarm(); return { success: true }; } @@ -345,7 +346,7 @@ export function registerGoogleCalendarMessageHandlers( patch.autoSyncWeekly = !!body.autoSyncWeekly; } if (Object.keys(patch).length > 0) { - await writeGoogleCalendarState(patch); + await writeSharedCalendarSyncSettings(patch); } await ensureWeeklySyncAlarm(); sendResponse({ success: true, ...(await getGoogleCalendarStatus()) }); @@ -359,7 +360,4 @@ export function registerGoogleCalendarMessageHandlers( }; } -export function initGoogleCalendarBackground(): void { - registerWeeklySyncAlarmListener(); - void ensureWeeklySyncAlarm(); -} +export { initCalendarBackground as initGoogleCalendarBackground } from "./calendarWeekly"; diff --git a/src/background/outlookCalendar.ts b/src/background/outlookCalendar.ts new file mode 100644 index 00000000..bbbd96c8 --- /dev/null +++ b/src/background/outlookCalendar.ts @@ -0,0 +1,302 @@ +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/outlookCalendar.ts b/src/config/outlookCalendar.ts new file mode 100644 index 00000000..bac80183 --- /dev/null +++ b/src/config/outlookCalendar.ts @@ -0,0 +1,43 @@ +/** + * Outlook Calendar OAuth — public client config (extension). + * Client secret and token exchange live on accounts.betterseqta.org. + * See docs/OUTLOOK_CALENDAR_ACCOUNTS_CALLBACK.md + */ + +import { ACCOUNTS_BASE } from "@/config/googleCalendar"; + +const HARDCODED_OUTLOOK_OAUTH_CLIENT_ID = + "0b55168c-916c-4323-8f67-b3dd30af3c9e"; + +const envClientId = + typeof __OUTLOOK_OAUTH_CLIENT_ID__ !== "undefined" ? __OUTLOOK_OAUTH_CLIENT_ID__ : ""; + +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 = + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; + +export const OUTLOOK_GRAPH_API = "https://graph.microsoft.com/v1.0"; + +export const BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY = "BetterSEQTA+"; + +export function isOutlookCalendarConfigured(): boolean { + return OUTLOOK_OAUTH_CLIENT_ID.length > 0; +} + +export const OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT = + "Outlook Calendar connect requires accounts.betterseqta.org — see docs/OUTLOOK_CALENDAR_ACCOUNTS_CALLBACK.md"; + +export function outlookOAuthRedirectUriHint(): string { + return `Authorized redirect URI in Azure must be: ${OUTLOOK_CALENDAR_OAUTH_CALLBACK}`; +} diff --git a/src/env.d.ts b/src/env.d.ts index 6fda995d..9e093d36 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -3,3 +3,4 @@ declare const __GH_RELEASE_REPO__: string; declare const __UPDATE_CHANNEL__: "stable" | "nightly"; declare const __BUILD_LABEL__: string; declare const __GOOGLE_OAUTH_CLIENT_ID__: string; +declare const __OUTLOOK_OAUTH_CLIENT_ID__: string; diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index 812f5de8..439930a8 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -22,13 +22,15 @@ "https://accounts.betterseqta.org/", "https://www.googleapis.com/", "https://oauth2.googleapis.com/", + "https://graph.microsoft.com/", + "https://login.microsoftonline.com/", "*://*/*" ], "background": { "service_worker": "background.ts" }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org https://www.googleapis.com https://oauth2.googleapis.com" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org https://www.googleapis.com https://oauth2.googleapis.com https://graph.microsoft.com https://login.microsoftonline.com" }, "content_scripts": [ { diff --git a/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte b/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte index 5accdd94..585fad24 100644 --- a/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte +++ b/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte @@ -4,11 +4,13 @@ let { open = false, busy = false, + providerLabel = "Google", onConfirm, onCancel, } = $props<{ open?: boolean; busy?: boolean; + providerLabel?: string; onConfirm: () => void | Promise; onCancel: () => void; }>(); @@ -37,7 +39,7 @@ Remove synced events?

- This removes all BetterSEQTA+ timetable events from your Google Calendar for this school. + This removes all BetterSEQTA+ timetable events from your {providerLabel} Calendar for this school. Your connection stays active — you can sync again later.

diff --git a/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte b/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte index 4f7990c1..3a6f7076 100644 --- a/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte +++ b/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte @@ -4,11 +4,13 @@ let { open = false, busy = false, + providerLabel = "Google", onConfirm, onCancel, } = $props<{ open?: boolean; busy?: boolean; + providerLabel?: string; onConfirm: () => void | Promise; onCancel: () => void; }>(); @@ -34,10 +36,10 @@ transition:fade={{ duration: 180 }} >

- Disconnect Google Calendar? + Disconnect {providerLabel} Calendar?

- Your synced timetable events will stay in Google Calendar, but BetterSEQTA+ will stop + Your synced timetable events will stay in {providerLabel} Calendar, but BetterSEQTA+ will stop updating them until you connect again.

diff --git a/src/plugins/built-in/timetable/CalendarSyncControl.svelte b/src/plugins/built-in/timetable/CalendarSyncControl.svelte index 7ea0b243..6610929e 100644 --- a/src/plugins/built-in/timetable/CalendarSyncControl.svelte +++ b/src/plugins/built-in/timetable/CalendarSyncControl.svelte @@ -8,6 +8,10 @@ } from "@/config/googleCalendar"; import { maybeRunDueWeeklySync } from "@/seqta/utils/googleCalendar/calendarSyncListener"; import { deleteSyncedEventsFromGoogleCalendar } from "@/seqta/utils/googleCalendar/syncEngine"; + import { + formatOutlookSyncResultMessage, + runOutlookCalendarSync, + } from "@/seqta/utils/outlookCalendar/syncRunner"; import { formatSyncResultMessage, runGoogleCalendarSync, @@ -17,17 +21,24 @@ GoogleCalendarSyncProgress, GoogleCalendarSyncResult, } from "@/seqta/utils/googleCalendar/types"; + import type { OutlookCalendarStatus } from "@/seqta/utils/outlookCalendar/types"; + import { deleteSyncedEventsFromOutlookCalendar } from "@/seqta/utils/outlookCalendar/syncEngine"; import CalendarDeleteEventsModal from "./CalendarDeleteEventsModal.svelte"; import CalendarDisconnectModal from "./CalendarDisconnectModal.svelte"; import CalendarSyncProgress from "./CalendarSyncProgress.svelte"; + import OutlookCalendarIcon from "./OutlookCalendarIcon.svelte"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { syncCalendarSyncTheme } from "./calendarSyncTheme"; + type CalendarProvider = "google" | "outlook"; type BusyPhase = "connect" | "sync" | "delete" | "disconnect" | null; + type BusyState = { provider: CalendarProvider; phase: BusyPhase } | null; - let status = $state({ configured: true, connected: false }); - let busy = $state(null); + let googleStatus = $state({ configured: true, connected: false }); + let outlookStatus = $state({ configured: true, connected: false }); + let busy = $state(null); let menuOpen = $state(false); + let modalProvider = $state(null); let showDisconnect = $state(false); let showDeleteEvents = $state(false); let toast = $state<{ message: string; error: boolean } | null>(null); @@ -42,8 +53,17 @@ let toastTimer: ReturnType | null = null; const isBusy = $derived(busy !== null); + const anyConnected = $derived(googleStatus.connected || outlookStatus.connected); const accent = "var(--bsplus-cal-accent, var(--better-main, #3b82f6))"; + function isProviderBusy(provider: CalendarProvider): boolean { + return busy?.provider === provider; + } + + function providerPhase(provider: CalendarProvider): BusyPhase { + return busy?.provider === provider ? busy.phase : null; + } + function showToastMessage(message: string, isError = false) { toast = { message, error: isError }; if (toastTimer) clearTimeout(toastTimer); @@ -53,19 +73,26 @@ } async function refreshStatus() { - status = (await browser.runtime.sendMessage({ - type: "googleCalendarStatus", - })) as GoogleCalendarStatus; - syncWeeksAhead = status.syncWeeksAhead ?? 12; - autoSyncWeekly = status.autoSyncWeekly !== false; + const [google, outlook] = await Promise.all([ + browser.runtime.sendMessage({ type: "googleCalendarStatus" }) as Promise, + browser.runtime.sendMessage({ type: "outlookCalendarStatus" }) as Promise, + ]); + googleStatus = google; + outlookStatus = outlook; + syncWeeksAhead = google.syncWeeksAhead ?? 12; + autoSyncWeekly = google.autoSyncWeekly !== false; } - async function getAccessTokenFromBackground(): Promise { - const res = (await browser.runtime.sendMessage({ - type: "googleCalendarGetAccessToken", - })) as { success?: boolean; accessToken?: string; error?: string }; + async function getAccessToken(provider: CalendarProvider): Promise { + const messageType = + provider === "google" ? "googleCalendarGetAccessToken" : "outlookCalendarGetAccessToken"; + 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 ?? "Could not get Google Calendar access token."); + throw new Error(res?.error ?? "Could not get calendar access token."); } return res.accessToken; } @@ -84,15 +111,17 @@ })) as GoogleCalendarStatus & { success?: boolean }; if (result.syncWeeksAhead != null) syncWeeksAhead = result.syncWeeksAhead; if (result.autoSyncWeekly != null) autoSyncWeekly = result.autoSyncWeekly; - status = { ...status, ...result }; + googleStatus = { ...googleStatus, ...result }; } - async function performSync(mode: "full" | "incremental" = "full"): Promise { - const result = await runGoogleCalendarSync({ - mode, - onProgress: handleSyncProgress, - }); + async function performSync( + provider: CalendarProvider, + mode: "full" | "incremental" = "full", + ): Promise { + const run = provider === "google" ? runGoogleCalendarSync : runOutlookCalendarSync; + const format = provider === "google" ? formatSyncResultMessage : formatOutlookSyncResultMessage; + const result = await run({ mode, onProgress: handleSyncProgress }); syncProgress = null; if (!result.success) { @@ -100,30 +129,47 @@ return false; } - status = { - ...status, - connected: true, - lastSyncAt: result.lastSyncAt ?? status.lastSyncAt, - }; - showToastMessage(formatSyncResultMessage(result)); + if (provider === "google") { + googleStatus = { + ...googleStatus, + connected: true, + lastSyncAt: result.lastSyncAt ?? googleStatus.lastSyncAt, + }; + } else { + outlookStatus = { + ...outlookStatus, + connected: true, + lastSyncAt: result.lastSyncAt ?? outlookStatus.lastSyncAt, + }; + } + + showToastMessage(format(result)); return true; } - async function connectGoogle() { + async function connectProvider(provider: CalendarProvider) { + const status = provider === "google" ? googleStatus : outlookStatus; if (!status.configured || isBusy) return; menuOpen = true; - busy = "connect"; + busy = { provider, phase: "connect" }; + const connectType = + provider === "google" ? "googleCalendarConnect" : "outlookCalendarConnect"; try { const result = (await browser.runtime.sendMessage({ - type: "googleCalendarConnect", + type: connectType, })) as GoogleCalendarSyncResult; if (!result.success) { - showToastMessage(result.error ?? "Could not connect to Google Calendar.", true); + const label = provider === "google" ? "Google" : "Outlook"; + showToastMessage(result.error ?? `Could not connect to ${label} Calendar.`, true); return; } - status = { ...status, connected: true }; - busy = "sync"; - await performSync(); + if (provider === "google") { + googleStatus = { ...googleStatus, connected: true }; + } else { + outlookStatus = { ...outlookStatus, connected: true }; + } + busy = { provider, phase: "sync" }; + await performSync(provider); } catch (err) { showToastMessage(err instanceof Error ? err.message : "Could not connect.", true); } finally { @@ -132,16 +178,17 @@ } } - async function syncTimetable() { + async function syncProvider(provider: CalendarProvider) { + const status = provider === "google" ? googleStatus : outlookStatus; if (!status.configured || isBusy) return; if (!status.connected) { - await connectGoogle(); + await connectProvider(provider); return; } - busy = "sync"; + busy = { provider, phase: "sync" }; try { - await performSync(); + await performSync(provider); } catch (err) { showToastMessage(err instanceof Error ? err.message : "Calendar sync failed.", true); } finally { @@ -151,8 +198,9 @@ } async function confirmDeleteEvents() { - if (isBusy) return; - busy = "delete"; + if (isBusy || !modalProvider) return; + const provider = modalProvider; + busy = { provider, phase: "delete" }; syncProgress = { phase: "preparing", current: 0, @@ -160,11 +208,13 @@ message: "Preparing removal…", }; try { - const result = await deleteSyncedEventsFromGoogleCalendar( - location.origin, - getAccessTokenFromBackground, - { onProgress: handleSyncProgress }, - ); + const deleteFn = + provider === "google" + ? deleteSyncedEventsFromGoogleCalendar + : deleteSyncedEventsFromOutlookCalendar; + const result = await deleteFn(location.origin, () => getAccessToken(provider), { + onProgress: handleSyncProgress, + }); if (!result.success) { showToastMessage(result.error ?? "Could not remove calendar events.", true); @@ -174,6 +224,7 @@ const removed = result.deleted ?? 0; showDeleteEvents = false; menuOpen = false; + modalProvider = null; if (removed === 0) { showToastMessage("No synced events to remove."); } else { @@ -200,20 +251,29 @@ } async function confirmDisconnect() { - if (isBusy) return; - busy = "disconnect"; + if (isBusy || !modalProvider) return; + const provider = modalProvider; + busy = { provider, phase: "disconnect" }; + const disconnectType = + provider === "google" ? "googleCalendarDisconnect" : "outlookCalendarDisconnect"; + const label = provider === "google" ? "Google" : "Outlook"; try { const result = (await browser.runtime.sendMessage({ - type: "googleCalendarDisconnect", + type: disconnectType, })) as { success?: boolean }; if (!result?.success) { - showToastMessage("Could not disconnect Google Calendar.", true); + showToastMessage(`Could not disconnect ${label} Calendar.`, true); return; } - status = { ...status, connected: false, lastSyncAt: undefined }; + if (provider === "google") { + googleStatus = { ...googleStatus, connected: false, lastSyncAt: undefined }; + } else { + outlookStatus = { ...outlookStatus, connected: false, lastSyncAt: undefined }; + } showDisconnect = false; menuOpen = false; - showToastMessage("Disconnected from Google Calendar."); + modalProvider = null; + showToastMessage(`Disconnected from ${label} Calendar.`); } catch (err) { showToastMessage(err instanceof Error ? err.message : "Disconnect failed.", true); } finally { @@ -294,6 +354,7 @@ const themeKeys = [ "selectedColor", + "selectedFont", "DarkMode", "adaptiveThemeColour", "adaptiveThemeGradient", @@ -333,22 +394,22 @@
@@ -397,10 +458,10 @@ Calendar - {#if !status.configured} + {#if !googleStatus.configured} Not available in this build - {:else if status.connected} - Connected{formatLastSync(status.lastSyncAt) ? ` · ${formatLastSync(status.lastSyncAt)}` : ""} + {:else if googleStatus.connected} + Connected{formatLastSync(googleStatus.lastSyncAt) ? ` · ${formatLastSync(googleStatus.lastSyncAt)}` : ""} {:else} Not connected {/if} @@ -408,8 +469,129 @@
- {#if status.connected} -
+
+ {#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} +
+
+ + {#if anyConnected} +
-
More providers - Outlook, Apple Calendar — coming soon + Apple Calendar — coming soon
{/if} { - if (busy !== "delete") showDeleteEvents = false; + if (busy?.phase !== "delete") showDeleteEvents = false; }} onConfirm={confirmDeleteEvents} /> { - if (busy !== "disconnect") showDisconnect = false; + if (busy?.phase !== "disconnect") showDisconnect = false; }} onConfirm={confirmDisconnect} /> @@ -530,7 +665,7 @@ .bsplus-cal-sync { position: relative; display: inline-flex; - font-family: Rubik, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-family: var(--bsplus-cal-font-family, var(--betterseqta-font-family, Rubik), sans-serif); color: var(--bsplus-cal-text, var(--text-primary, #111)); } @@ -545,16 +680,19 @@ padding: 0 10px; margin-left: 4px; border-radius: 16px !important; + font-family: inherit; transition: all 0.2s ease; } .bsplus-cal-trigger-icon { + font-family: "IconFamily" !important; font-size: 16px; line-height: 1; opacity: 0.9; } .bsplus-cal-trigger-text { + font-family: inherit; font-size: 13px; font-weight: 600; line-height: 1; @@ -680,7 +818,7 @@ color: var(--bsplus-cal-text, #18181b); border: 1px solid var(--bsplus-cal-border, color-mix(in srgb, var(--bsplus-cal-text) 12%, transparent)); box-shadow: 0 16px 40px rgba(0, 0, 0, 0.22); - font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-family: var(--bsplus-cal-font-family, var(--betterseqta-font-family, Rubik), sans-serif); } .bsplus-cal-menu.dark { @@ -710,6 +848,11 @@ padding: 8px; border-radius: 10px; background: var(--bsplus-cal-surface-muted, color-mix(in srgb, var(--bsplus-cal-text) 4%, transparent)); + margin-bottom: 8px; + } + + .bsplus-cal-provider--outlook { + margin-bottom: 0; } .bsplus-cal-provider-row { diff --git a/src/plugins/built-in/timetable/OutlookCalendarIcon.svelte b/src/plugins/built-in/timetable/OutlookCalendarIcon.svelte new file mode 100644 index 00000000..23bb5e1f --- /dev/null +++ b/src/plugins/built-in/timetable/OutlookCalendarIcon.svelte @@ -0,0 +1,231 @@ + diff --git a/src/plugins/built-in/timetable/calendarSyncHost.css b/src/plugins/built-in/timetable/calendarSyncHost.css index b91b5d5e..c27f092d 100644 --- a/src/plugins/built-in/timetable/calendarSyncHost.css +++ b/src/plugins/built-in/timetable/calendarSyncHost.css @@ -20,4 +20,5 @@ .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 index 357ff638..a8320b20 100644 --- a/src/plugins/built-in/timetable/calendarSyncTheme.ts +++ b/src/plugins/built-in/timetable/calendarSyncTheme.ts @@ -1,5 +1,7 @@ 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", @@ -42,6 +44,10 @@ function resolvePageAccentColor(): string { 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 = diff --git a/src/plugins/built-in/timetable/calendarSyncUi.ts b/src/plugins/built-in/timetable/calendarSyncUi.ts index 45fa003a..bb39c2a6 100644 --- a/src/plugins/built-in/timetable/calendarSyncUi.ts +++ b/src/plugins/built-in/timetable/calendarSyncUi.ts @@ -1,7 +1,7 @@ import { mount, unmount } from "svelte"; import CalendarSyncControl from "./CalendarSyncControl.svelte"; import { syncCalendarSyncTheme } from "./calendarSyncTheme"; -import { registerGoogleCalendarContentHandlers } from "@/seqta/utils/googleCalendar/calendarSyncListener"; +import { registerCalendarContentHandlers } from "@/seqta/utils/googleCalendar/calendarSyncListener"; import hostStyles from "./calendarSyncHost.css?inline"; const CONTROLS_CLASS = "timetable-calendar-controls"; @@ -36,7 +36,7 @@ export async function mountGoogleCalendarButton(): Promise { if (!toolbar) return; ensureHostStyles(); - registerGoogleCalendarContentHandlers(); + registerCalendarContentHandlers(); const controls = document.createElement("div"); controls.className = `${CONTROLS_CLASS} bsplus-timetable-control`; diff --git a/src/seqta/utils/calendarSync/settings.ts b/src/seqta/utils/calendarSync/settings.ts new file mode 100644 index 00000000..ab6e1fd2 --- /dev/null +++ b/src/seqta/utils/calendarSync/settings.ts @@ -0,0 +1,65 @@ +import { + GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT, + 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"; + +export { CALENDAR_WEEKLY_ALARM, WEEKLY_SYNC_INTERVAL_MS } from "./sharedSettings"; + +export function clampSyncWeeks(weeks: number): number { + if (!Number.isFinite(weeks)) return GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT; + return Math.min( + GOOGLE_CALENDAR_SYNC_WEEKS_MAX, + Math.max(GOOGLE_CALENDAR_SYNC_WEEKS_MIN, Math.round(weeks)), + ); +} + +export async function getSyncWeeksAhead(): Promise { + const settings = await readSharedCalendarSyncSettings(); + return clampSyncWeeks(settings.syncWeeksAhead ?? GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT); +} + +export async function getAutoSyncWeekly(): Promise { + const settings = await readSharedCalendarSyncSettings(); + return settings.autoSyncWeekly !== false; +} + +async function isAnyCalendarConnected(): Promise { + const [google, outlook] = await Promise.all([ + readGoogleCalendarState(), + readOutlookCalendarState(), + ]); + return !!( + google.refreshToken || + google.accessToken || + outlook.refreshToken || + outlook.accessToken + ); +} + +export async function shouldRunWeeklySync(): Promise { + const settings = await readSharedCalendarSyncSettings(); + if (settings.autoSyncWeekly === false) return false; + if (!(await isAnyCalendarConnected())) return false; + if (settings.pendingWeeklySync) return true; + const last = settings.lastWeeklySyncAt ?? 0; + return Date.now() - last >= WEEKLY_SYNC_INTERVAL_MS; +} + +export async function markWeeklySyncComplete(): Promise { + await writeSharedCalendarSyncSettings({ + lastWeeklySyncAt: Date.now(), + pendingWeeklySync: false, + }); +} + +export async function markWeeklySyncPending(): Promise { + await writeSharedCalendarSyncSettings({ pendingWeeklySync: true }); +} diff --git a/src/seqta/utils/calendarSync/sharedSettings.ts b/src/seqta/utils/calendarSync/sharedSettings.ts new file mode 100644 index 00000000..543728b3 --- /dev/null +++ b/src/seqta/utils/calendarSync/sharedSettings.ts @@ -0,0 +1,48 @@ +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/googleCalendar/calendarSyncListener.ts b/src/seqta/utils/googleCalendar/calendarSyncListener.ts index 5a4911e7..66daf582 100644 --- a/src/seqta/utils/googleCalendar/calendarSyncListener.ts +++ b/src/seqta/utils/googleCalendar/calendarSyncListener.ts @@ -1,28 +1,60 @@ import browser from "webextension-polyfill"; -import { shouldRunWeeklySync } from "@/seqta/utils/googleCalendar/syncSettings"; +import { + markWeeklySyncComplete, + shouldRunWeeklySync, +} from "@/seqta/utils/calendarSync/settings"; import { formatSyncResultMessage, runGoogleCalendarSync, } from "@/seqta/utils/googleCalendar/syncRunner"; +import { + formatOutlookSyncResultMessage, + runOutlookCalendarSync, +} from "@/seqta/utils/outlookCalendar/syncRunner"; +import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; +import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage"; import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; let listenerRegistered = false; -export function registerGoogleCalendarContentHandlers(): void { +async function runWeeklySyncForConnectedProviders(): Promise { + const [google, outlook] = await Promise.all([ + readGoogleCalendarState(), + readOutlookCalendarState(), + ]); + const results: GoogleCalendarSyncResult[] = []; + + 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 })); + } + + if (results.some((r) => r.success)) { + await markWeeklySyncComplete(); + } + + return results; +} + +export function registerCalendarContentHandlers(): void { if (listenerRegistered) return; listenerRegistered = true; browser.runtime.onMessage.addListener((request, _sender, sendResponse) => { - if (request?.type !== "googleCalendarRunWeeklySync") return false; - void runGoogleCalendarSync({ mode: "incremental", silent: true }) - .then((result: GoogleCalendarSyncResult) => sendResponse(result)) - .catch((err: unknown) => { - sendResponse({ - success: false, - error: err instanceof Error ? err.message : "Weekly sync failed", + if (request?.type === "calendarRunWeeklySync" || request?.type === "googleCalendarRunWeeklySync") { + void runWeeklySyncForConnectedProviders() + .then((results) => sendResponse({ success: true, results })) + .catch((err: unknown) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Weekly sync failed", + }); }); - }); - return true; + return true; + } + return false; }); } @@ -31,17 +63,38 @@ export async function maybeRunDueWeeklySync( ): Promise { if (!(await shouldRunWeeklySync())) return; - const result = await runGoogleCalendarSync({ mode: "incremental", silent: true }); + const [google, outlook] = await Promise.all([ + readGoogleCalendarState(), + readOutlookCalendarState(), + ]); + const results = await runWeeklySyncForConnectedProviders(); if (!onComplete) return; - if (!result.success) { - onComplete(result.error ?? "Weekly calendar sync failed.", true); + const errors = results.filter((r) => !r.success); + if (errors.length > 0) { + onComplete(errors[0]?.error ?? "Weekly calendar sync failed.", true); return; } - const changed = - (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0; - if (changed) { - onComplete(formatSyncResultMessage(result)); + const messages: string[] = []; + let index = 0; + if (google.refreshToken || google.accessToken) { + const result = results[index++]; + const changed = + (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0; + if (changed) messages.push(formatSyncResultMessage(result)); + } + if (outlook.refreshToken || outlook.accessToken) { + const result = results[index++]; + const changed = + (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0; + if (changed) messages.push(formatOutlookSyncResultMessage(result)); + } + + if (messages.length > 0) { + onComplete(messages.join(" ")); } } + +/** @deprecated use registerCalendarContentHandlers */ +export const registerGoogleCalendarContentHandlers = registerCalendarContentHandlers; diff --git a/src/seqta/utils/googleCalendar/syncEngine.test.ts b/src/seqta/utils/googleCalendar/syncEngine.test.ts index 300dbee8..595ef9d7 100644 --- a/src/seqta/utils/googleCalendar/syncEngine.test.ts +++ b/src/seqta/utils/googleCalendar/syncEngine.test.ts @@ -15,7 +15,7 @@ jest.mock("@/seqta/utils/googleCalendar/storage", () => ({ writeGoogleCalendarState: jest.fn(async (patch: unknown) => patch), })); -jest.mock("@/seqta/utils/googleCalendar/syncSettings", () => ({ +jest.mock("@/seqta/utils/calendarSync/settings", () => ({ getSyncWeeksAhead: jest.fn(async () => 12), })); diff --git a/src/seqta/utils/googleCalendar/syncEngine.ts b/src/seqta/utils/googleCalendar/syncEngine.ts index bd12e137..bbc146c2 100644 --- a/src/seqta/utils/googleCalendar/syncEngine.ts +++ b/src/seqta/utils/googleCalendar/syncEngine.ts @@ -10,7 +10,7 @@ import { isDateInRange, syncWindowRange, } from "@/seqta/utils/googleCalendar/syncDateRange"; -import { getSyncWeeksAhead } from "@/seqta/utils/googleCalendar/syncSettings"; +import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; import { eventMapKey, readGoogleCalendarState, diff --git a/src/seqta/utils/googleCalendar/syncRunner.ts b/src/seqta/utils/googleCalendar/syncRunner.ts index d3c80543..79b23ed9 100644 --- a/src/seqta/utils/googleCalendar/syncRunner.ts +++ b/src/seqta/utils/googleCalendar/syncRunner.ts @@ -4,10 +4,7 @@ import { fetchTimetableLessons, trailingWeekRange, } from "@/seqta/utils/googleCalendar/fetchTimetable"; -import { - getSyncWeeksAhead, - markWeeklySyncComplete, -} from "@/seqta/utils/googleCalendar/syncSettings"; +import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; import { syncLessonsToGoogleCalendar } from "@/seqta/utils/googleCalendar/syncEngine"; import type { GoogleCalendarSyncOptions, @@ -63,10 +60,6 @@ export async function runGoogleCalendarSync( options, ); - if (result.success && mode === "incremental") { - await markWeeklySyncComplete(); - } - return result; } diff --git a/src/seqta/utils/googleCalendar/syncSettings.ts b/src/seqta/utils/googleCalendar/syncSettings.ts index 492685ec..3f1ab00e 100644 --- a/src/seqta/utils/googleCalendar/syncSettings.ts +++ b/src/seqta/utils/googleCalendar/syncSettings.ts @@ -1,57 +1,10 @@ -import { - GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT, - GOOGLE_CALENDAR_SYNC_WEEKS_MAX, - GOOGLE_CALENDAR_SYNC_WEEKS_MIN, -} from "@/config/googleCalendar"; -import { readGoogleCalendarState, writeGoogleCalendarState } from "./storage"; - -export const GOOGLE_CALENDAR_WEEKLY_ALARM = "bsplus_google_calendar_weekly"; -export const WEEKLY_SYNC_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; - -export function clampSyncWeeks(weeks: number): number { - if (!Number.isFinite(weeks)) return GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT; - return Math.min( - GOOGLE_CALENDAR_SYNC_WEEKS_MAX, - Math.max(GOOGLE_CALENDAR_SYNC_WEEKS_MIN, Math.round(weeks)), - ); -} - -export async function getSyncWeeksAhead(): Promise { - const state = await readGoogleCalendarState(); - return clampSyncWeeks(state.syncWeeksAhead ?? GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT); -} - -export async function setSyncWeeksAhead(weeks: number): Promise { - const syncWeeksAhead = clampSyncWeeks(weeks); - await writeGoogleCalendarState({ syncWeeksAhead }); - return syncWeeksAhead; -} - -export async function getAutoSyncWeekly(): Promise { - const state = await readGoogleCalendarState(); - return state.autoSyncWeekly !== false; -} - -export async function setAutoSyncWeekly(enabled: boolean): Promise { - await writeGoogleCalendarState({ autoSyncWeekly: enabled }); -} - -export async function shouldRunWeeklySync(): Promise { - const state = await readGoogleCalendarState(); - if (!state.refreshToken && !state.accessToken) return false; - if (state.autoSyncWeekly === false) return false; - if (state.pendingWeeklySync) return true; - const last = state.lastWeeklySyncAt ?? state.lastSyncAt ?? 0; - return Date.now() - last >= WEEKLY_SYNC_INTERVAL_MS; -} - -export async function markWeeklySyncComplete(): Promise { - await writeGoogleCalendarState({ - lastWeeklySyncAt: Date.now(), - pendingWeeklySync: false, - }); -} - -export async function markWeeklySyncPending(): Promise { - await writeGoogleCalendarState({ pendingWeeklySync: true }); -} +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/outlookCalendar/accountsToken.ts b/src/seqta/utils/outlookCalendar/accountsToken.ts new file mode 100644 index 00000000..7a57e916 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/accountsToken.ts @@ -0,0 +1,77 @@ +import { + OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT, + OUTLOOK_CALENDAR_REFRESH_URL, + OUTLOOK_CALENDAR_TOKEN_URL, +} from "@/config/outlookCalendar"; + +type OutlookTokenPayload = { + 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): OutlookTokenPayload { + 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 OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT; + } + const err = typeof json.error === "string" ? json.error : ""; + const desc = typeof json.error_description === "string" ? json.error_description : ""; + return desc || err || `Accounts token API failed (${res.status})`; +} + +export async function exchangeOutlookCodeViaAccounts( + code: string, + redirectUri: string, + codeVerifier: string, +): Promise { + const res = await fetch(OUTLOOK_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 refreshOutlookTokenViaAccounts( + refreshToken: string, +): Promise { + const res = await fetch(OUTLOOK_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/outlookCalendar/eventMapper.ts b/src/seqta/utils/outlookCalendar/eventMapper.ts new file mode 100644 index 00000000..9fd490db --- /dev/null +++ b/src/seqta/utils/outlookCalendar/eventMapper.ts @@ -0,0 +1,36 @@ +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 new file mode 100644 index 00000000..1b5f5f0a --- /dev/null +++ b/src/seqta/utils/outlookCalendar/storage.ts @@ -0,0 +1,38 @@ +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}`; +} diff --git a/src/seqta/utils/outlookCalendar/syncEngine.ts b/src/seqta/utils/outlookCalendar/syncEngine.ts new file mode 100644 index 00000000..dd3c3815 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/syncEngine.ts @@ -0,0 +1,348 @@ +import { verboseLog } from "@/utils/verboseLog"; +import { isOutlookCalendarConfigured } from "@/config/outlookCalendar"; +import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; +import { + getStoredEventId, + lessonDateFromSeqtaKey, + normalizeEventMapEntry, +} from "@/seqta/utils/googleCalendar/eventMapEntry"; +import { + isDateInRange, + syncWindowRange, +} from "@/seqta/utils/googleCalendar/syncDateRange"; +import type { + GoogleCalendarDeleteResult, + GoogleCalendarSyncOptions, + GoogleCalendarSyncProgress, + 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"; + +const EVENT_MAP_PERSIST_EVERY = 10; + +type DeleteTrackedEventsResult = { + deleted: number; + failed: number; +}; + +function reportProgress( + onProgress: GoogleCalendarSyncOptions["onProgress"], + progress: GoogleCalendarSyncProgress, +) { + onProgress?.(progress); +} + +function lessonDateForEvent(startDateTime: string, seqtaKey: string): string { + return startDateTime.slice(0, 10) || lessonDateFromSeqtaKey(seqtaKey) || ""; +} + +async function deleteTrackedEventsFromOutlook( + entries: Array<[string, string]>, + eventMap: Record, + 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; + + reportProgress(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; + reportProgress(onProgress, { + phase: "deleting", + current: progressOffset + deleted + failed, + total: progressTotal, + message: `Removing old events (${deleted + failed}/${entries.length})…`, + }); + } + } + + return { deleted, failed }; +} + +function originEventMapEntries( + eventMap: Record, + origin: string, +): Array<[string, string]> { + const prefix = `${origin}::`; + const entries: Array<[string, string]> = []; + for (const [key, value] of Object.entries(eventMap)) { + if (!key.startsWith(prefix)) continue; + const id = getStoredEventId(value); + if (id) entries.push([key, id]); + } + return entries; +} + +function entriesToPrune( + eventMap: Record, + origin: string, + mode: "full" | "incremental", + weeksAhead: number, + currentMapKeys: Set, +): Array<[string, string]> { + const window = syncWindowRange(weeksAhead); + const prefix = `${origin}::`; + const entries: Array<[string, string]> = []; + + for (const [mapKey, raw] of Object.entries(eventMap)) { + if (!mapKey.startsWith(prefix)) continue; + const entry = normalizeEventMapEntry(raw); + if (!entry) continue; + + let shouldDelete = false; + if (mode === "incremental") { + shouldDelete = false; + } else if (entry.date) { + shouldDelete = !isDateInRange(entry.date, window); + } else { + shouldDelete = !currentMapKeys.has(mapKey); + } + + if (shouldDelete) entries.push([mapKey, entry.id]); + } + + return entries; +} + +export async function syncLessonsToOutlookCalendar( + request: GoogleCalendarSyncRequest, + getAccessToken: () => Promise, + options: GoogleCalendarSyncOptions = {}, +): Promise { + if (!isOutlookCalendarConfigured()) { + return { + success: false, + configured: false, + error: "Outlook Calendar is not configured in this extension build.", + }; + } + + const state = await readOutlookCalendarState(); + if (!state.refreshToken && !state.accessToken) { + return { success: false, configured: true, connected: false, error: "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 { + success: false, + configured: true, + connected: true, + error: "No timetable classes found to sync for the selected range.", + }; + } + + reportProgress(options.onProgress, { + phase: "preparing", + current: 0, + total: Math.max(events.length, 1), + message: mode === "incremental" ? "Preparing weekly sync…" : "Preparing sync…", + }); + + let accessToken = await getAccessToken(); + const eventMap = { ...(state.eventMap ?? {}) }; + const currentMapKeys = new Set( + events.map((event) => outlookEventMapKey(request.origin, event.seqtaKey)), + ); + const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys); + const totalSteps = staleEntries.length + events.length; + + const staleResult = await deleteTrackedEventsFromOutlook( + staleEntries, + eventMap, + getAccessToken, + false, + options.onProgress, + 0, + totalSteps, + ); + + let created = 0; + let updated = 0; + let failed = staleResult.failed; + const lastSyncAt = Date.now(); + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + const mapKey = outlookEventMapKey(request.origin, event.seqtaKey); + const existingId = getStoredEventId(eventMap[mapKey]); + try { + const outlookId = await upsertOutlookCalendarEvent( + accessToken, + existingId, + outlookGraphEventBody(event), + async () => { + accessToken = await getAccessToken(); + return accessToken; + }, + ); + if (existingId) updated += 1; + else created += 1; + eventMap[mapKey] = { + id: outlookId, + date: lessonDateForEvent(event.startDateTime, event.seqtaKey), + }; + + reportProgress(options.onProgress, { + phase: "upserting", + current: staleEntries.length + i + 1, + total: totalSteps, + message: `Syncing events (${i + 1}/${events.length})…`, + }); + + if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) { + await writeOutlookCalendarState({ + eventMap, + lastSyncAt, + lastSyncOrigin: request.origin, + }); + } + } catch (err) { + verboseLog("[BetterSEQTA+] Outlook Calendar event sync failed:", err); + failed += 1; + reportProgress(options.onProgress, { + phase: "upserting", + current: staleEntries.length + i + 1, + total: totalSteps, + message: `Syncing events (${i + 1}/${events.length})…`, + }); + } + } + + if (staleResult.deleted > 0 || staleEntries.length > 0 || events.length > 0) { + await writeOutlookCalendarState({ + eventMap, + lastSyncAt, + lastSyncOrigin: request.origin, + }); + } + + reportProgress(options.onProgress, { + phase: "done", + current: totalSteps, + total: totalSteps, + message: "Sync complete", + }); + + return { + success: failed === 0, + configured: true, + connected: true, + created, + updated, + deleted: staleResult.deleted, + skipped: 0, + failed, + lastSyncAt, + error: + failed > 0 + ? `Synced with ${failed} error${failed === 1 ? "" : "s"}. Check the console for details.` + : undefined, + }; +} + +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 }; + } + + reportProgress(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 }); + + reportProgress(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 new file mode 100644 index 00000000..4758f642 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/syncRunner.ts @@ -0,0 +1,76 @@ +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 new file mode 100644 index 00000000..75b1a23c --- /dev/null +++ b/src/seqta/utils/outlookCalendar/types.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 00000000..410075e0 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/upsertEvent.test.ts @@ -0,0 +1,48 @@ +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; + +describe("upsertOutlookCalendarEvent", () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + it("creates a new event when no existing id", async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: "evt-1" }), + }); + + 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" }), + ); + }); + + it("patches when an existing id is provided", async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); + + const id = await upsertOutlookCalendarEvent("token", "evt-1", { subject: "Math" }); + expect(id).toBe("evt-1"); + expect(fetchMock).toHaveBeenCalledWith( + "https://graph.microsoft.com/v1.0/me/events/evt-1", + expect.objectContaining({ method: "PATCH" }), + ); + }); +}); + +describe("deleteOutlookCalendarEvent", () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + it("treats 404 as success", async () => { + fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) }); + await expect(deleteOutlookCalendarEvent("token", "evt-1")).resolves.toBeUndefined(); + }); +}); diff --git a/src/seqta/utils/outlookCalendar/upsertEvent.ts b/src/seqta/utils/outlookCalendar/upsertEvent.ts new file mode 100644 index 00000000..1c5fc182 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/upsertEvent.ts @@ -0,0 +1,68 @@ +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/vite.config.ts b/vite.config.ts index c85800cd..59573f87 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -80,6 +80,7 @@ export default defineConfig(({ command, mode: viteMode }) => { __UPDATE_CHANNEL__: JSON.stringify(process.env.UPDATE_CHANNEL ?? "stable"), __BUILD_LABEL__: JSON.stringify(process.env.BUILD_LABEL ?? ""), __GOOGLE_OAUTH_CLIENT_ID__: JSON.stringify(env.GOOGLE_OAUTH_CLIENT_ID ?? ""), + __OUTLOOK_OAUTH_CLIENT_ID__: JSON.stringify(env.OUTLOOK_OAUTH_CLIENT_ID ?? ""), }, envDir: repoRoot, plugins: [ From 95da84ff1630b03b445df6fb65ef4b407b2a5f1f Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sat, 27 Jun 2026 15:31:12 +0930 Subject: [PATCH 6/6] fix: appease the ci godas --- jest.config.js | 1 + .../timetable/CalendarSyncControl.svelte | 32 +-- .../utils/calendarSync/lessonSyncShared.ts | 236 ++++++++++++++++++ .../utils/calendarSync/weeklySyncMessages.ts | 51 ++++ .../googleCalendar/calendarSyncListener.ts | 34 +-- src/seqta/utils/googleCalendar/syncEngine.ts | 212 +++++----------- src/seqta/utils/outlookCalendar/syncEngine.ts | 211 +++++----------- src/test/mocks/color.ts | 86 +++++++ 8 files changed, 497 insertions(+), 366 deletions(-) create mode 100644 src/seqta/utils/calendarSync/lessonSyncShared.ts create mode 100644 src/seqta/utils/calendarSync/weeklySyncMessages.ts create mode 100644 src/test/mocks/color.ts diff --git a/jest.config.js b/jest.config.js index 428e2515..6234dba9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,6 +11,7 @@ export default { }, moduleNameMapper: { '^@/(.*)$': '/src/$1', + '^color$': '/src/test/mocks/color.ts', '^webextension-polyfill$': '/src/test/mocks/webextension-polyfill.ts', }, moduleFileExtensions: ['ts', 'js', 'json'], diff --git a/src/plugins/built-in/timetable/CalendarSyncControl.svelte b/src/plugins/built-in/timetable/CalendarSyncControl.svelte index 6610929e..63f8809b 100644 --- a/src/plugins/built-in/timetable/CalendarSyncControl.svelte +++ b/src/plugins/built-in/timetable/CalendarSyncControl.svelte @@ -520,7 +520,7 @@ -
+
{/if} @@ -851,10 +846,6 @@ margin-bottom: 8px; } - .bsplus-cal-provider--outlook { - margin-bottom: 0; - } - .bsplus-cal-provider-row { display: flex; align-items: center; @@ -933,27 +924,6 @@ background: color-mix(in srgb, var(--bsplus-cal-text) 14%, var(--bsplus-cal-surface)); } - .bsplus-cal-coming-soon { - margin-top: 8px; - padding: 8px 10px; - border-radius: 8px; - border: 1px dashed var(--bsplus-cal-border, color-mix(in srgb, var(--bsplus-cal-text) 18%, transparent)); - } - - .bsplus-cal-coming-soon-label { - display: block; - font-size: 11px; - font-weight: 600; - color: color-mix(in srgb, var(--bsplus-cal-text, #111) 70%, transparent); - } - - .bsplus-cal-coming-soon-hint { - display: block; - margin-top: 2px; - font-size: 10px; - color: color-mix(in srgb, var(--bsplus-cal-text, #111) 50%, transparent); - } - .bsplus-cal-toast { position: fixed; right: 16px; diff --git a/src/seqta/utils/calendarSync/lessonSyncShared.ts b/src/seqta/utils/calendarSync/lessonSyncShared.ts new file mode 100644 index 00000000..07e3b94f --- /dev/null +++ b/src/seqta/utils/calendarSync/lessonSyncShared.ts @@ -0,0 +1,236 @@ +import { verboseLog } from "@/utils/verboseLog"; +import { + getStoredEventId, + lessonDateFromSeqtaKey, + normalizeEventMapEntry, +} from "@/seqta/utils/googleCalendar/eventMapEntry"; +import { + isDateInRange, + syncWindowRange, +} from "@/seqta/utils/googleCalendar/syncDateRange"; +import type { + GoogleCalendarSyncOptions, + GoogleCalendarSyncProgress, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; + +export const EVENT_MAP_PERSIST_EVERY = 10; + +export type EventMapRecord = Record; + +export type MappedLessonEvent = { + seqtaKey: string; + startDateTime: string; +}; + +export function reportSyncProgress( + onProgress: GoogleCalendarSyncOptions["onProgress"], + progress: GoogleCalendarSyncProgress, +) { + onProgress?.(progress); +} + +export function lessonDateForEvent(startDateTime: string, seqtaKey: string): string { + return startDateTime.slice(0, 10) || lessonDateFromSeqtaKey(seqtaKey) || ""; +} + +export function originEventMapEntries( + eventMap: EventMapRecord, + origin: string, +): Array<[string, string]> { + const prefix = `${origin}::`; + const entries: Array<[string, string]> = []; + for (const [key, value] of Object.entries(eventMap)) { + if (!key.startsWith(prefix)) continue; + const id = getStoredEventId(value); + if (id) entries.push([key, id]); + } + return entries; +} + +function shouldPruneEntry( + mode: "full" | "incremental", + entry: { id: string; date: string }, + mapKey: string, + window: ReturnType, + currentMapKeys: Set, +): boolean { + if (mode === "incremental") return false; + if (entry.date) return !isDateInRange(entry.date, window); + return !currentMapKeys.has(mapKey); +} + +export function entriesToPrune( + eventMap: EventMapRecord, + origin: string, + mode: "full" | "incremental", + weeksAhead: number, + currentMapKeys: Set, +): Array<[string, string]> { + const window = syncWindowRange(weeksAhead); + const prefix = `${origin}::`; + const entries: Array<[string, string]> = []; + + for (const [mapKey, raw] of Object.entries(eventMap)) { + if (!mapKey.startsWith(prefix)) continue; + const entry = normalizeEventMapEntry(raw); + if (!entry) continue; + if (shouldPruneEntry(mode, entry, mapKey, window, currentMapKeys)) { + entries.push([mapKey, entry.id]); + } + } + + return entries; +} + +export function notConfiguredSyncResult(error: string): GoogleCalendarSyncResult { + return { success: false, configured: false, error }; +} + +export function notConnectedSyncResult(error: string): GoogleCalendarSyncResult { + return { success: false, configured: true, connected: false, error }; +} + +export function emptyLessonsSyncResult(): GoogleCalendarSyncResult { + return { + success: false, + configured: true, + connected: true, + error: "No timetable classes found to sync for the selected range.", + }; +} + +export function buildLessonSyncResult( + created: number, + updated: number, + deleted: number, + failed: number, + lastSyncAt: number, +): GoogleCalendarSyncResult { + return { + success: failed === 0, + configured: true, + connected: true, + created, + updated, + deleted, + skipped: 0, + failed, + lastSyncAt, + error: + failed > 0 + ? `Synced with ${failed} error${failed === 1 ? "" : "s"}. Check the console for details.` + : undefined, + }; +} + +export async function persistFinalSyncState( + writeState: (patch: { + eventMap: EventMapRecord; + lastSyncAt: number; + lastSyncOrigin: string; + }) => Promise, + eventMap: EventMapRecord, + lastSyncAt: number, + origin: string, + staleDeleted: number, + staleEntryCount: number, + eventCount: number, +): Promise { + if (staleDeleted > 0 || staleEntryCount > 0 || eventCount > 0) { + await writeState({ eventMap, lastSyncAt, lastSyncOrigin: origin }); + } +} + +type UpsertLessonEventsParams = { + events: TEvent[]; + eventMap: EventMapRecord; + origin: string; + staleEntryCount: number; + totalSteps: number; + lastSyncAt: number; + initialFailed: number; + getAccessToken: () => Promise; + mapKey: (origin: string, seqtaKey: string) => string; + upsert: ( + accessToken: string, + existingId: string | undefined, + event: TEvent, + refreshAccessToken: () => Promise, + ) => Promise; + writeState: (patch: { + eventMap: EventMapRecord; + lastSyncAt: number; + lastSyncOrigin: string; + }) => Promise; + onProgress?: GoogleCalendarSyncOptions["onProgress"]; + logLabel: string; +}; + +export async function upsertLessonEvents( + params: UpsertLessonEventsParams, +): Promise<{ created: number; updated: number; failed: number; accessToken: string }> { + const { + events, + eventMap, + origin, + staleEntryCount, + totalSteps, + lastSyncAt, + initialFailed, + getAccessToken, + mapKey, + upsert, + writeState, + onProgress, + logLabel, + } = params; + + let accessToken = await getAccessToken(); + let created = 0; + let updated = 0; + let failed = initialFailed; + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + const key = mapKey(origin, event.seqtaKey); + const existingId = getStoredEventId(eventMap[key]); + const progressCurrent = staleEntryCount + i + 1; + const progressMessage = `Syncing events (${i + 1}/${events.length})…`; + + try { + const remoteId = await upsert(accessToken, existingId, event, async () => { + accessToken = await getAccessToken(); + return accessToken; + }); + if (existingId) updated += 1; + else created += 1; + eventMap[key] = { + id: remoteId, + date: lessonDateForEvent(event.startDateTime, event.seqtaKey), + }; + + reportSyncProgress(onProgress, { + phase: "upserting", + current: progressCurrent, + total: totalSteps, + message: progressMessage, + }); + + if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) { + await writeState({ eventMap, lastSyncAt, lastSyncOrigin: origin }); + } + } catch (err) { + verboseLog(`[BetterSEQTA+] ${logLabel} event sync failed:`, err); + failed += 1; + reportSyncProgress(onProgress, { + phase: "upserting", + current: progressCurrent, + total: totalSteps, + message: progressMessage, + }); + } + } + + return { created, updated, failed, accessToken }; +} diff --git a/src/seqta/utils/calendarSync/weeklySyncMessages.ts b/src/seqta/utils/calendarSync/weeklySyncMessages.ts new file mode 100644 index 00000000..249be3a7 --- /dev/null +++ b/src/seqta/utils/calendarSync/weeklySyncMessages.ts @@ -0,0 +1,51 @@ +import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; +import { formatSyncResultMessage } from "@/seqta/utils/googleCalendar/syncRunner"; +import { formatOutlookSyncResultMessage } from "@/seqta/utils/outlookCalendar/syncRunner"; + +type ProviderCalendarState = { + refreshToken?: string; + accessToken?: string; +}; + +function isProviderConnected(state: ProviderCalendarState): boolean { + return Boolean(state.refreshToken || state.accessToken); +} + +function providerHadChanges(result: GoogleCalendarSyncResult): boolean { + return ( + (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0 + ); +} + +export function weeklySyncErrorMessage( + results: GoogleCalendarSyncResult[], +): string | undefined { + const failed = results.find((result) => !result.success); + if (!failed) return undefined; + return failed.error ?? "Weekly calendar sync failed."; +} + +export function formatWeeklySyncMessages( + google: ProviderCalendarState, + outlook: ProviderCalendarState, + results: GoogleCalendarSyncResult[], +): string[] { + const messages: string[] = []; + let index = 0; + + if (isProviderConnected(google)) { + const result = results[index++]; + if (result && providerHadChanges(result)) { + messages.push(formatSyncResultMessage(result)); + } + } + + if (isProviderConnected(outlook)) { + const result = results[index++]; + if (result && providerHadChanges(result)) { + messages.push(formatOutlookSyncResultMessage(result)); + } + } + + return messages; +} diff --git a/src/seqta/utils/googleCalendar/calendarSyncListener.ts b/src/seqta/utils/googleCalendar/calendarSyncListener.ts index 66daf582..e4d750eb 100644 --- a/src/seqta/utils/googleCalendar/calendarSyncListener.ts +++ b/src/seqta/utils/googleCalendar/calendarSyncListener.ts @@ -4,13 +4,11 @@ import { shouldRunWeeklySync, } from "@/seqta/utils/calendarSync/settings"; import { - formatSyncResultMessage, - runGoogleCalendarSync, -} from "@/seqta/utils/googleCalendar/syncRunner"; -import { - formatOutlookSyncResultMessage, - runOutlookCalendarSync, -} from "@/seqta/utils/outlookCalendar/syncRunner"; + formatWeeklySyncMessages, + weeklySyncErrorMessage, +} from "@/seqta/utils/calendarSync/weeklySyncMessages"; +import { runGoogleCalendarSync } from "@/seqta/utils/googleCalendar/syncRunner"; +import { runOutlookCalendarSync } from "@/seqta/utils/outlookCalendar/syncRunner"; import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage"; import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; @@ -70,27 +68,13 @@ export async function maybeRunDueWeeklySync( const results = await runWeeklySyncForConnectedProviders(); if (!onComplete) return; - const errors = results.filter((r) => !r.success); - if (errors.length > 0) { - onComplete(errors[0]?.error ?? "Weekly calendar sync failed.", true); + const errorMessage = weeklySyncErrorMessage(results); + if (errorMessage) { + onComplete(errorMessage, true); return; } - const messages: string[] = []; - let index = 0; - if (google.refreshToken || google.accessToken) { - const result = results[index++]; - const changed = - (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0; - if (changed) messages.push(formatSyncResultMessage(result)); - } - if (outlook.refreshToken || outlook.accessToken) { - const result = results[index++]; - const changed = - (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0; - if (changed) messages.push(formatOutlookSyncResultMessage(result)); - } - + const messages = formatWeeklySyncMessages(google, outlook, results); if (messages.length > 0) { onComplete(messages.join(" ")); } diff --git a/src/seqta/utils/googleCalendar/syncEngine.ts b/src/seqta/utils/googleCalendar/syncEngine.ts index bbc146c2..2517043a 100644 --- a/src/seqta/utils/googleCalendar/syncEngine.ts +++ b/src/seqta/utils/googleCalendar/syncEngine.ts @@ -1,16 +1,19 @@ import { verboseLog } from "@/utils/verboseLog"; import { isGoogleCalendarConfigured } from "@/config/googleCalendar"; import { googleApiEventBody, mapLessonsToGoogleEvents } from "@/seqta/utils/googleCalendar/eventMapper"; -import { - getStoredEventId, - lessonDateFromSeqtaKey, - normalizeEventMapEntry, -} from "@/seqta/utils/googleCalendar/eventMapEntry"; -import { - isDateInRange, - syncWindowRange, -} from "@/seqta/utils/googleCalendar/syncDateRange"; import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; +import { + buildLessonSyncResult, + emptyLessonsSyncResult, + entriesToPrune, + EVENT_MAP_PERSIST_EVERY, + notConfiguredSyncResult, + notConnectedSyncResult, + originEventMapEntries, + persistFinalSyncState, + reportSyncProgress, + upsertLessonEvents, +} from "@/seqta/utils/calendarSync/lessonSyncShared"; import { eventMapKey, readGoogleCalendarState, @@ -19,7 +22,6 @@ import { import type { GoogleCalendarDeleteResult, GoogleCalendarSyncOptions, - GoogleCalendarSyncProgress, GoogleCalendarSyncRequest, GoogleCalendarSyncResult, } from "@/seqta/utils/googleCalendar/types"; @@ -28,7 +30,6 @@ import { upsertGoogleCalendarEvent, } from "@/seqta/utils/googleCalendar/upsertEvent"; -const EVENT_MAP_PERSIST_EVERY = 10; const CALENDAR_ID = "primary"; type DeleteTrackedEventsResult = { @@ -36,17 +37,6 @@ type DeleteTrackedEventsResult = { failed: number; }; -function reportProgress( - onProgress: GoogleCalendarSyncOptions["onProgress"], - progress: GoogleCalendarSyncProgress, -) { - onProgress?.(progress); -} - -function lessonDateForEvent(startDateTime: string, seqtaKey: string): string { - return startDateTime.slice(0, 10) || lessonDateFromSeqtaKey(seqtaKey) || ""; -} - async function deleteTrackedEventsFromGoogle( entries: Array<[string, string]>, eventMap: Record, @@ -72,7 +62,7 @@ async function deleteTrackedEventsFromGoogle( delete eventMap[mapKey]; deleted += 1; - reportProgress(onProgress, { + reportSyncProgress(onProgress, { phase: "deleting", current: progressOffset + deleted + failed, total: progressTotal, @@ -85,7 +75,7 @@ async function deleteTrackedEventsFromGoogle( } catch (err) { verboseLog("[BetterSEQTA+] Google Calendar event delete failed:", err); failed += 1; - reportProgress(onProgress, { + reportSyncProgress(onProgress, { phase: "deleting", current: progressOffset + deleted + failed, total: progressTotal, @@ -97,51 +87,6 @@ async function deleteTrackedEventsFromGoogle( return { deleted, failed }; } -function originEventMapEntries( - eventMap: Record, - origin: string, -): Array<[string, string]> { - const prefix = `${origin}::`; - const entries: Array<[string, string]> = []; - for (const [key, value] of Object.entries(eventMap)) { - if (!key.startsWith(prefix)) continue; - const id = getStoredEventId(value); - if (id) entries.push([key, id]); - } - return entries; -} - -function entriesToPrune( - eventMap: Record, - origin: string, - mode: "full" | "incremental", - weeksAhead: number, - currentMapKeys: Set, -): Array<[string, string]> { - const window = syncWindowRange(weeksAhead); - const prefix = `${origin}::`; - const entries: Array<[string, string]> = []; - - for (const [mapKey, raw] of Object.entries(eventMap)) { - if (!mapKey.startsWith(prefix)) continue; - const entry = normalizeEventMapEntry(raw); - if (!entry) continue; - - let shouldDelete = false; - if (mode === "incremental") { - shouldDelete = false; - } else if (entry.date) { - shouldDelete = !isDateInRange(entry.date, window); - } else { - shouldDelete = !currentMapKeys.has(mapKey); - } - - if (shouldDelete) entries.push([mapKey, entry.id]); - } - - return entries; -} - /** Runs in the content script tab so long syncs are not killed by the MV3 service worker. */ export async function syncLessonsToGoogleCalendar( request: GoogleCalendarSyncRequest, @@ -149,16 +94,14 @@ export async function syncLessonsToGoogleCalendar( options: GoogleCalendarSyncOptions = {}, ): Promise { if (!isGoogleCalendarConfigured()) { - return { - success: false, - configured: false, - error: "Google Calendar is not configured in this extension build.", - }; + return notConfiguredSyncResult( + "Google Calendar is not configured in this extension build.", + ); } const state = await readGoogleCalendarState(); if (!state.refreshToken && !state.accessToken) { - return { success: false, configured: true, connected: false, error: "Connect Google Calendar first." }; + return notConnectedSyncResult("Connect Google Calendar first."); } const mode = request.mode ?? "full"; @@ -167,26 +110,21 @@ export async function syncLessonsToGoogleCalendar( const events = mapLessonsToGoogleEvents(request.origin, request.lessons, timeZone); if (events.length === 0 && mode === "full") { - return { - success: false, - configured: true, - connected: true, - error: "No timetable classes found to sync for the selected range.", - }; + return emptyLessonsSyncResult(); } - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "preparing", current: 0, total: Math.max(events.length, 1), message: mode === "incremental" ? "Preparing weekly sync…" : "Preparing sync…", }); - let accessToken = await getAccessToken(); const eventMap = { ...(state.eventMap ?? {}) }; const currentMapKeys = new Set(events.map((event) => eventMapKey(request.origin, event.seqtaKey))); const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys); const totalSteps = staleEntries.length + events.length; + const lastSyncAt = Date.now(); const staleResult = await deleteTrackedEventsFromGoogle( staleEntries, @@ -198,89 +136,53 @@ export async function syncLessonsToGoogleCalendar( totalSteps, ); - let created = 0; - let updated = 0; - let failed = staleResult.failed; - const lastSyncAt = Date.now(); - - for (let i = 0; i < events.length; i++) { - const event = events[i]; - const mapKey = eventMapKey(request.origin, event.seqtaKey); - const existingId = getStoredEventId(eventMap[mapKey]); - try { - const googleId = await upsertGoogleCalendarEvent( + const upsertResult = await upsertLessonEvents({ + events, + eventMap, + origin: request.origin, + staleEntryCount: staleEntries.length, + totalSteps, + lastSyncAt, + initialFailed: staleResult.failed, + getAccessToken, + mapKey: eventMapKey, + upsert: (accessToken, existingId, event, refreshAccessToken) => + upsertGoogleCalendarEvent( accessToken, CALENDAR_ID, existingId, googleApiEventBody(event), - async () => { - accessToken = await getAccessToken(); - return accessToken; - }, - ); - if (existingId) updated += 1; - else created += 1; - eventMap[mapKey] = { - id: googleId, - date: lessonDateForEvent(event.startDateTime, event.seqtaKey), - }; + refreshAccessToken, + ), + writeState: writeGoogleCalendarState, + onProgress: options.onProgress, + logLabel: "Google Calendar", + }); - reportProgress(options.onProgress, { - phase: "upserting", - current: staleEntries.length + i + 1, - total: totalSteps, - message: `Syncing events (${i + 1}/${events.length})…`, - }); + await persistFinalSyncState( + writeGoogleCalendarState, + eventMap, + lastSyncAt, + request.origin, + staleResult.deleted, + staleEntries.length, + events.length, + ); - if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) { - await writeGoogleCalendarState({ - eventMap, - lastSyncAt, - lastSyncOrigin: request.origin, - }); - } - } catch (err) { - verboseLog("[BetterSEQTA+] Google Calendar event sync failed:", err); - failed += 1; - reportProgress(options.onProgress, { - phase: "upserting", - current: staleEntries.length + i + 1, - total: totalSteps, - message: `Syncing events (${i + 1}/${events.length})…`, - }); - } - } - - if (staleResult.deleted > 0 || staleEntries.length > 0 || events.length > 0) { - await writeGoogleCalendarState({ - eventMap, - lastSyncAt, - lastSyncOrigin: request.origin, - }); - } - - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "done", current: totalSteps, total: totalSteps, message: "Sync complete", }); - return { - success: failed === 0, - configured: true, - connected: true, - created, - updated, - deleted: staleResult.deleted, - skipped: 0, - failed, + return buildLessonSyncResult( + upsertResult.created, + upsertResult.updated, + staleResult.deleted, + upsertResult.failed, lastSyncAt, - error: - failed > 0 - ? `Synced with ${failed} error${failed === 1 ? "" : "s"}. Check the console for details.` - : undefined, - }; + ); } /** Delete all tracked BetterSEQTA+ events for this SEQTA origin from Google Calendar. */ @@ -307,7 +209,7 @@ export async function deleteSyncedEventsFromGoogleCalendar( return { success: true, configured: true, connected: true, deleted: 0, failed: 0 }; } - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "preparing", current: 0, total: entries.length, @@ -327,7 +229,7 @@ export async function deleteSyncedEventsFromGoogleCalendar( await writeGoogleCalendarState({ eventMap }); - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "done", current: entries.length, total: entries.length, diff --git a/src/seqta/utils/outlookCalendar/syncEngine.ts b/src/seqta/utils/outlookCalendar/syncEngine.ts index dd3c3815..345ea1dc 100644 --- a/src/seqta/utils/outlookCalendar/syncEngine.ts +++ b/src/seqta/utils/outlookCalendar/syncEngine.ts @@ -2,18 +2,20 @@ import { verboseLog } from "@/utils/verboseLog"; import { isOutlookCalendarConfigured } from "@/config/outlookCalendar"; import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; import { - getStoredEventId, - lessonDateFromSeqtaKey, - normalizeEventMapEntry, -} from "@/seqta/utils/googleCalendar/eventMapEntry"; -import { - isDateInRange, - syncWindowRange, -} from "@/seqta/utils/googleCalendar/syncDateRange"; + buildLessonSyncResult, + emptyLessonsSyncResult, + entriesToPrune, + EVENT_MAP_PERSIST_EVERY, + notConfiguredSyncResult, + notConnectedSyncResult, + originEventMapEntries, + persistFinalSyncState, + reportSyncProgress, + upsertLessonEvents, +} from "@/seqta/utils/calendarSync/lessonSyncShared"; import type { GoogleCalendarDeleteResult, GoogleCalendarSyncOptions, - GoogleCalendarSyncProgress, GoogleCalendarSyncRequest, GoogleCalendarSyncResult, } from "@/seqta/utils/googleCalendar/types"; @@ -31,24 +33,11 @@ import { upsertOutlookCalendarEvent, } from "@/seqta/utils/outlookCalendar/upsertEvent"; -const EVENT_MAP_PERSIST_EVERY = 10; - type DeleteTrackedEventsResult = { deleted: number; failed: number; }; -function reportProgress( - onProgress: GoogleCalendarSyncOptions["onProgress"], - progress: GoogleCalendarSyncProgress, -) { - onProgress?.(progress); -} - -function lessonDateForEvent(startDateTime: string, seqtaKey: string): string { - return startDateTime.slice(0, 10) || lessonDateFromSeqtaKey(seqtaKey) || ""; -} - async function deleteTrackedEventsFromOutlook( entries: Array<[string, string]>, eventMap: Record, @@ -73,7 +62,7 @@ async function deleteTrackedEventsFromOutlook( delete eventMap[mapKey]; deleted += 1; - reportProgress(onProgress, { + reportSyncProgress(onProgress, { phase: "deleting", current: progressOffset + deleted + failed, total: progressTotal, @@ -86,7 +75,7 @@ async function deleteTrackedEventsFromOutlook( } catch (err) { verboseLog("[BetterSEQTA+] Outlook Calendar event delete failed:", err); failed += 1; - reportProgress(onProgress, { + reportSyncProgress(onProgress, { phase: "deleting", current: progressOffset + deleted + failed, total: progressTotal, @@ -98,67 +87,20 @@ async function deleteTrackedEventsFromOutlook( return { deleted, failed }; } -function originEventMapEntries( - eventMap: Record, - origin: string, -): Array<[string, string]> { - const prefix = `${origin}::`; - const entries: Array<[string, string]> = []; - for (const [key, value] of Object.entries(eventMap)) { - if (!key.startsWith(prefix)) continue; - const id = getStoredEventId(value); - if (id) entries.push([key, id]); - } - return entries; -} - -function entriesToPrune( - eventMap: Record, - origin: string, - mode: "full" | "incremental", - weeksAhead: number, - currentMapKeys: Set, -): Array<[string, string]> { - const window = syncWindowRange(weeksAhead); - const prefix = `${origin}::`; - const entries: Array<[string, string]> = []; - - for (const [mapKey, raw] of Object.entries(eventMap)) { - if (!mapKey.startsWith(prefix)) continue; - const entry = normalizeEventMapEntry(raw); - if (!entry) continue; - - let shouldDelete = false; - if (mode === "incremental") { - shouldDelete = false; - } else if (entry.date) { - shouldDelete = !isDateInRange(entry.date, window); - } else { - shouldDelete = !currentMapKeys.has(mapKey); - } - - if (shouldDelete) entries.push([mapKey, entry.id]); - } - - return entries; -} - export async function syncLessonsToOutlookCalendar( request: GoogleCalendarSyncRequest, getAccessToken: () => Promise, options: GoogleCalendarSyncOptions = {}, ): Promise { if (!isOutlookCalendarConfigured()) { - return { - success: false, - configured: false, - error: "Outlook Calendar is not configured in this extension build.", - }; + return notConfiguredSyncResult( + "Outlook Calendar is not configured in this extension build.", + ); } const state = await readOutlookCalendarState(); if (!state.refreshToken && !state.accessToken) { - return { success: false, configured: true, connected: false, error: "Connect Outlook Calendar first." }; + return notConnectedSyncResult("Connect Outlook Calendar first."); } const mode = request.mode ?? "full"; @@ -167,28 +109,23 @@ export async function syncLessonsToOutlookCalendar( const events = mapLessonsToOutlookEvents(request.origin, request.lessons, timeZone); if (events.length === 0 && mode === "full") { - return { - success: false, - configured: true, - connected: true, - error: "No timetable classes found to sync for the selected range.", - }; + return emptyLessonsSyncResult(); } - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "preparing", current: 0, total: Math.max(events.length, 1), message: mode === "incremental" ? "Preparing weekly sync…" : "Preparing sync…", }); - let accessToken = await getAccessToken(); const eventMap = { ...(state.eventMap ?? {}) }; const currentMapKeys = new Set( events.map((event) => outlookEventMapKey(request.origin, event.seqtaKey)), ); const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys); const totalSteps = staleEntries.length + events.length; + const lastSyncAt = Date.now(); const staleResult = await deleteTrackedEventsFromOutlook( staleEntries, @@ -200,88 +137,52 @@ export async function syncLessonsToOutlookCalendar( totalSteps, ); - let created = 0; - let updated = 0; - let failed = staleResult.failed; - const lastSyncAt = Date.now(); - - for (let i = 0; i < events.length; i++) { - const event = events[i]; - const mapKey = outlookEventMapKey(request.origin, event.seqtaKey); - const existingId = getStoredEventId(eventMap[mapKey]); - try { - const outlookId = await upsertOutlookCalendarEvent( + const upsertResult = await upsertLessonEvents({ + events, + eventMap, + origin: request.origin, + staleEntryCount: staleEntries.length, + totalSteps, + lastSyncAt, + initialFailed: staleResult.failed, + getAccessToken, + mapKey: outlookEventMapKey, + upsert: (accessToken, existingId, event, refreshAccessToken) => + upsertOutlookCalendarEvent( accessToken, existingId, outlookGraphEventBody(event), - async () => { - accessToken = await getAccessToken(); - return accessToken; - }, - ); - if (existingId) updated += 1; - else created += 1; - eventMap[mapKey] = { - id: outlookId, - date: lessonDateForEvent(event.startDateTime, event.seqtaKey), - }; + refreshAccessToken, + ), + writeState: writeOutlookCalendarState, + onProgress: options.onProgress, + logLabel: "Outlook Calendar", + }); - reportProgress(options.onProgress, { - phase: "upserting", - current: staleEntries.length + i + 1, - total: totalSteps, - message: `Syncing events (${i + 1}/${events.length})…`, - }); + await persistFinalSyncState( + writeOutlookCalendarState, + eventMap, + lastSyncAt, + request.origin, + staleResult.deleted, + staleEntries.length, + events.length, + ); - if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) { - await writeOutlookCalendarState({ - eventMap, - lastSyncAt, - lastSyncOrigin: request.origin, - }); - } - } catch (err) { - verboseLog("[BetterSEQTA+] Outlook Calendar event sync failed:", err); - failed += 1; - reportProgress(options.onProgress, { - phase: "upserting", - current: staleEntries.length + i + 1, - total: totalSteps, - message: `Syncing events (${i + 1}/${events.length})…`, - }); - } - } - - if (staleResult.deleted > 0 || staleEntries.length > 0 || events.length > 0) { - await writeOutlookCalendarState({ - eventMap, - lastSyncAt, - lastSyncOrigin: request.origin, - }); - } - - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "done", current: totalSteps, total: totalSteps, message: "Sync complete", }); - return { - success: failed === 0, - configured: true, - connected: true, - created, - updated, - deleted: staleResult.deleted, - skipped: 0, - failed, + return buildLessonSyncResult( + upsertResult.created, + upsertResult.updated, + staleResult.deleted, + upsertResult.failed, lastSyncAt, - error: - failed > 0 - ? `Synced with ${failed} error${failed === 1 ? "" : "s"}. Check the console for details.` - : undefined, - }; + ); } export async function deleteSyncedEventsFromOutlookCalendar( @@ -307,7 +208,7 @@ export async function deleteSyncedEventsFromOutlookCalendar( return { success: true, configured: true, connected: true, deleted: 0, failed: 0 }; } - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "preparing", current: 0, total: entries.length, @@ -327,7 +228,7 @@ export async function deleteSyncedEventsFromOutlookCalendar( await writeOutlookCalendarState({ eventMap }); - reportProgress(options.onProgress, { + reportSyncProgress(options.onProgress, { phase: "done", current: entries.length, total: entries.length, diff --git a/src/test/mocks/color.ts b/src/test/mocks/color.ts new file mode 100644 index 00000000..585602a5 --- /dev/null +++ b/src/test/mocks/color.ts @@ -0,0 +1,86 @@ +type ColorChannels = { + r: number; + g: number; + b: number; + a: number; +}; + +function clampByte(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function clampAlpha(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function toHexByte(value: number): string { + return clampByte(value).toString(16).padStart(2, "0"); +} + +function createColor(channels: ColorChannels) { + const color = { + red: () => clampByte(channels.r), + green: () => clampByte(channels.g), + blue: () => clampByte(channels.b), + alpha: () => clampAlpha(channels.a), + hex: () => + `#${toHexByte(channels.r)}${toHexByte(channels.g)}${toHexByte(channels.b)}`, + }; + + return { + ...color, + alpha: (value?: number) => { + if (value === undefined) return clampAlpha(channels.a); + return createColor({ ...channels, a: value }); + }, + }; +} + +function parseHex(input: string): ColorChannels | null { + const short = input.match(/^#([0-9a-f]{3})$/i); + if (short) { + const [r, g, b] = short[1].split(""); + return { + r: parseInt(`${r}${r}`, 16), + g: parseInt(`${g}${g}`, 16), + b: parseInt(`${b}${b}`, 16), + a: 1, + }; + } + + const long = input.match(/^#([0-9a-f]{6})$/i); + if (!long) return null; + + return { + r: parseInt(long[1].slice(0, 2), 16), + g: parseInt(long[1].slice(2, 4), 16), + b: parseInt(long[1].slice(4, 6), 16), + a: 1, + }; +} + +function parseRgb(input: string): ColorChannels | null { + const match = input.match( + /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i, + ); + if (!match) return null; + + return { + r: Number(match[1]), + g: Number(match[2]), + b: Number(match[3]), + a: match[4] !== undefined ? Number(match[4]) : 1, + }; +} + +function Color(input: string) { + const channels = parseHex(input) ?? parseRgb(input); + if (!channels) { + throw new Error(`Unable to parse color: ${input}`); + } + return createColor(channels); +} + +Color.rgb = (r: number, g: number, b: number) => createColor({ r, g, b, a: 1 }); + +export default Color;