From e6b8be6821378385486450d885c9d747d31c6cce Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sat, 27 Jun 2026 11:51:48 +0930 Subject: [PATCH] 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 }) => ({ }, }, }, -})); +}; +});