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 }) => ({ }, }, }, -})); +}; +});