diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..ded48c13 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# 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 + +# Outlook / Microsoft Graph (see docs/OUTLOOK_CALENDAR_ACCOUNTS_CALLBACK.md) +# OUTLOOK_OAUTH_CLIENT_ID=your-azure-application-client-id diff --git a/jest.config.js b/jest.config.js index b581d02a..a63ca1c1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,6 +15,7 @@ export default { ], moduleNameMapper: { '^@/(.*)$': '/src/$1', + '^color$': '/src/test/mocks/color.ts', '^webextension-polyfill$': '/src/test/mocks/webextension-polyfill.ts', }, setupFilesAfterEnv: ['/src/test/jest.setup.ts'], diff --git a/src/background.ts b/src/background.ts index fa4bb494..779fef38 100644 --- a/src/background.ts +++ b/src/background.ts @@ -14,6 +14,8 @@ import { } from "./background/cloudSettingsAutoSync"; import { getBsplusDeviceName } from "@/seqta/utils/bsplusDeviceName"; import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl"; +import { registerGoogleCalendarMessageHandlers, initGoogleCalendarBackground } from "./background/googleCalendar"; +import { registerOutlookCalendarMessageHandlers } from "./background/outlookCalendar"; /** * Session-only dev-mode override of the content API base. @@ -566,6 +568,10 @@ const MESSAGE_HANDLERS: Record = { }, }; +registerGoogleCalendarMessageHandlers(MESSAGE_HANDLERS, isTrustedSender); +registerOutlookCalendarMessageHandlers(MESSAGE_HANDLERS, isTrustedSender); +initGoogleCalendarBackground(); + 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/calendarWeekly.ts b/src/background/calendarWeekly.ts new file mode 100644 index 00000000..9a7d2090 --- /dev/null +++ b/src/background/calendarWeekly.ts @@ -0,0 +1,88 @@ +import browser from "webextension-polyfill"; +import { verboseLog } from "@/utils/verboseLog"; +import { + CALENDAR_WEEKLY_ALARM, + getAutoSyncWeekly, + markWeeklySyncPending, +} from "@/seqta/utils/calendarSync/settings"; +import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; +import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage"; + +const WEEKLY_PERIOD_MINUTES = 7 * 24 * 60; + +function isSeqtaTab(tab: browser.Tabs.Tab): boolean { + const title = tab.title ?? ""; + return title.includes("SEQTA Learn") || title.includes("SEQTA Engage"); +} + +async function isAnyCalendarConnected(): Promise { + const [google, outlook] = await Promise.all([ + readGoogleCalendarState(), + readOutlookCalendarState(), + ]); + return !!( + google.refreshToken || + google.accessToken || + outlook.refreshToken || + outlook.accessToken + ); +} + +export async function ensureWeeklySyncAlarm(): Promise { + const connected = await isAnyCalendarConnected(); + const enabled = await getAutoSyncWeekly(); + if (!connected || !enabled) { + await browser.alarms.clear(CALENDAR_WEEKLY_ALARM); + return; + } + + const existing = await browser.alarms.get(CALENDAR_WEEKLY_ALARM); + if (!existing) { + await browser.alarms.create(CALENDAR_WEEKLY_ALARM, { + periodInMinutes: WEEKLY_PERIOD_MINUTES, + }); + } +} + +export async function clearWeeklySyncAlarm(): Promise { + await browser.alarms.clear(CALENDAR_WEEKLY_ALARM); +} + +export async function triggerWeeklySyncOnSeqtaTabs(): Promise { + const tabs = await browser.tabs.query({}); + const seqtaTabs = tabs.filter((tab) => tab.id != null && isSeqtaTab(tab)); + if (seqtaTabs.length === 0) return false; + + let delivered = false; + for (const tab of seqtaTabs) { + if (tab.id == null) continue; + try { + await browser.tabs.sendMessage(tab.id, { type: "calendarRunWeeklySync" }); + delivered = true; + } catch (err) { + verboseLog("[BetterSEQTA+] Weekly calendar sync message failed for tab:", tab.id, err); + } + } + return delivered; +} + +export async function handleWeeklySyncAlarm(): Promise { + if (!(await isAnyCalendarConnected()) || !(await getAutoSyncWeekly())) return; + + const delivered = await triggerWeeklySyncOnSeqtaTabs(); + if (!delivered) { + await markWeeklySyncPending(); + } +} + +export function registerWeeklySyncAlarmListener(): void { + browser.alarms.onAlarm.addListener((alarm) => { + if (alarm.name !== CALENDAR_WEEKLY_ALARM) return; + void handleWeeklySyncAlarm(); + }); +} + +export function initCalendarBackground(): void { + registerWeeklySyncAlarmListener(); + void ensureWeeklySyncAlarm(); +} diff --git a/src/background/googleCalendar.ts b/src/background/googleCalendar.ts new file mode 100644 index 00000000..9042e855 --- /dev/null +++ b/src/background/googleCalendar.ts @@ -0,0 +1,363 @@ +import browser from "webextension-polyfill"; +import { + GOOGLE_AUTH_URL, + GOOGLE_CALENDAR_OAUTH_CALLBACK, + GOOGLE_CALENDAR_SCOPE, + GOOGLE_OAUTH_CLIENT_ID, + googleOAuthRedirectUriHint, + isGoogleCalendarConfigured, +} from "@/config/googleCalendar"; +import { + exchangeGoogleCodeViaAccounts, + refreshGoogleTokenViaAccounts, +} from "@/seqta/utils/googleCalendar/accountsToken"; +import { + clearGoogleCalendarState, + readGoogleCalendarState, + writeGoogleCalendarState, +} from "@/seqta/utils/googleCalendar/storage"; +import { + clampSyncWeeks, + getAutoSyncWeekly, + getSyncWeeksAhead, +} from "@/seqta/utils/calendarSync/settings"; +import { + readSharedCalendarSyncSettings, + writeSharedCalendarSyncSettings, +} from "@/seqta/utils/calendarSync/sharedSettings"; +import type { + GoogleCalendarStatus, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; +import { ensureWeeklySyncAlarm, initCalendarBackground } from "./calendarWeekly"; + +function base64UrlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +async function sha256(input: string): Promise { + const data = new TextEncoder().encode(input); + return crypto.subtle.digest("SHA-256", data); +} + +function randomVerifier(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes.buffer); +} + +async function pkceChallenge(verifier: string): Promise { + return base64UrlEncode(await sha256(verifier)); +} + +function parseRedirectCode(responseUrl: string): string { + const url = new URL(responseUrl); + const err = url.searchParams.get("error"); + if (err) throw new Error(url.searchParams.get("error_description") ?? err); + const code = url.searchParams.get("code"); + if (!code) throw new Error("Google sign-in did not return an authorization code."); + return code; +} + +const GOOGLE_OAUTH_CALLBACK_PREFIX = GOOGLE_CALENDAR_OAUTH_CALLBACK; +const GOOGLE_OAUTH_TIMEOUT_MS = 10 * 60 * 1000; + +function isGoogleOAuthCallbackUrl(url: string): boolean { + if (!url.startsWith(GOOGLE_OAUTH_CALLBACK_PREFIX)) return false; + const parsed = new URL(url); + return parsed.searchParams.has("code") || parsed.searchParams.has("error"); +} + +/** Tab-based OAuth (same pattern as cloud login) — launchWebAuthFlow does not reliably return external redirect URLs. */ +function waitForGoogleOAuthCallback(authTabId: number): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + cleanup(); + reject(new Error("Google sign-in timed out. Close the tab and try again.")); + }, GOOGLE_OAUTH_TIMEOUT_MS); + + const cleanup = () => { + clearTimeout(timeoutId); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.tabs.onRemoved.removeListener(onRemoved); + }; + + const finishFromUrl = (url: string, tabId: number) => { + if (!isGoogleOAuthCallbackUrl(url)) return false; + cleanup(); + void browser.tabs.remove(tabId).catch(() => {}); + resolve(url); + return true; + }; + + const onRemoved = (tabId: number) => { + if (tabId !== authTabId) return; + cleanup(); + reject(new Error("Google sign-in was cancelled.")); + }; + + const onUpdated = ( + tabId: number, + changeInfo: browser.Tabs.OnUpdatedChangeInfoType, + tab: browser.Tabs.Tab, + ) => { + if (tabId !== authTabId) return; + const url = + changeInfo.url ?? (changeInfo.status === "complete" ? tab.url : undefined); + if (url) finishFromUrl(url, tabId); + }; + + browser.tabs.onUpdated.addListener(onUpdated); + browser.tabs.onRemoved.addListener(onRemoved); + + void browser.tabs + .get(authTabId) + .then((tab) => { + if (tab.url) finishFromUrl(tab.url, authTabId); + }) + .catch(() => {}); + }); +} + +async function openGoogleOAuthTab(authUrl: string): Promise { + const tab = await browser.tabs.create({ url: authUrl, active: true }); + if (tab.id === undefined) { + throw new Error("Could not open Google sign-in tab."); + } + return waitForGoogleOAuthCallback(tab.id); +} + +async function getValidAccessToken(): Promise { + const state = await readGoogleCalendarState(); + const now = Date.now(); + if (state.accessToken && state.expiresAt && state.expiresAt > now + 60_000) { + return state.accessToken; + } + if (!state.refreshToken) { + throw new Error("Not connected to Google Calendar."); + } + const refreshed = await refreshGoogleTokenViaAccounts(state.refreshToken); + const expiresAt = refreshed.expires_in + ? Date.now() + refreshed.expires_in * 1000 + : Date.now() + 3_600_000; + await writeGoogleCalendarState({ + accessToken: refreshed.access_token, + refreshToken: refreshed.refresh_token ?? state.refreshToken, + expiresAt, + }); + return refreshed.access_token; +} + +async function connectGoogleCalendar(): Promise { + if (!isGoogleCalendarConfigured()) { + return { + success: false, + configured: false, + error: "Google Calendar is not configured in this extension build.", + }; + } + + const redirectUri = GOOGLE_CALENDAR_OAUTH_CALLBACK; + const verifier = randomVerifier(); + const challenge = await pkceChallenge(verifier); + const authUrl = new URL(GOOGLE_AUTH_URL); + authUrl.searchParams.set("client_id", GOOGLE_OAUTH_CLIENT_ID); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("scope", GOOGLE_CALENDAR_SCOPE); + authUrl.searchParams.set("access_type", "offline"); + authUrl.searchParams.set("prompt", "consent"); + authUrl.searchParams.set("code_challenge", challenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + + let responseUrl: string; + try { + responseUrl = await openGoogleOAuthTab(authUrl.toString()); + } catch (err) { + const message = err instanceof Error ? err.message : "Google sign-in failed"; + const mismatch = + /redirect_uri_mismatch|invalid_request/i.test(message) || + /redirect_uri_mismatch|invalid_request/i.test(String(err)); + return { + success: false, + configured: true, + error: mismatch + ? `Google redirect URI mismatch. ${googleOAuthRedirectUriHint()}` + : message.includes("cancel") + ? "Google sign-in was cancelled." + : message, + }; + } + + try { + const code = parseRedirectCode(responseUrl); + const tokens = await exchangeGoogleCodeViaAccounts(code, redirectUri, verifier); + const expiresAt = tokens.expires_in + ? Date.now() + tokens.expires_in * 1000 + : Date.now() + 3_600_000; + + const existing = await readGoogleCalendarState(); + await writeGoogleCalendarState({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? existing.refreshToken, + expiresAt, + connectedAt: Date.now(), + }); + + await ensureWeeklySyncAlarm(); + + return { success: true, configured: true, connected: true }; + } catch (err) { + return { + success: false, + configured: true, + error: err instanceof Error ? err.message : "Google sign-in failed", + }; + } +} + +async function getGoogleCalendarStatus(): Promise { + const state = await readGoogleCalendarState(); + const shared = await readSharedCalendarSyncSettings(); + const syncWeeksAhead = await getSyncWeeksAhead(); + const autoSyncWeekly = await getAutoSyncWeekly(); + return { + configured: isGoogleCalendarConfigured(), + connected: !!(state.refreshToken || state.accessToken), + lastSyncAt: state.lastSyncAt, + lastWeeklySyncAt: shared.lastWeeklySyncAt, + lastSyncOrigin: state.lastSyncOrigin, + syncWeeksAhead, + autoSyncWeekly, + }; +} + +export async function handleGoogleCalendarConnect(): Promise { + return connectGoogleCalendar(); +} + +export async function handleGoogleCalendarDisconnect(): Promise<{ success: boolean }> { + await clearGoogleCalendarState(); + await ensureWeeklySyncAlarm(); + return { success: true }; +} + +export async function handleGoogleCalendarStatus(): Promise { + return getGoogleCalendarStatus(); +} + +export function registerGoogleCalendarMessageHandlers( + handlers: Record< + string, + ( + request: unknown, + sendResponse: (response?: unknown) => void, + sender?: browser.Runtime.MessageSender, + ) => boolean | void + >, + isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean, +): void { + const rejectUntrusted = ( + sendResponse: (response?: unknown) => void, + sender?: browser.Runtime.MessageSender, + ): boolean => { + if (isTrustedSender(sender)) return false; + sendResponse({ success: false, error: "Unauthorized sender" }); + return true; + }; + + handlers.googleCalendarConnect = (_req, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void handleGoogleCalendarConnect() + .then(sendResponse) + .catch((err) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Google sign-in failed", + }); + }); + return true; + }; + + handlers.googleCalendarDisconnect = (_req, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void handleGoogleCalendarDisconnect() + .then(sendResponse) + .catch((err) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Disconnect failed", + }); + }); + return true; + }; + + handlers.googleCalendarStatus = (_req, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void handleGoogleCalendarStatus() + .then(sendResponse) + .catch(() => { + sendResponse({ configured: isGoogleCalendarConfigured(), connected: false }); + }); + return true; + }; + + handlers.googleCalendarGetAccessToken = (_req, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void getValidAccessToken() + .then((accessToken) => sendResponse({ success: true, accessToken })) + .catch((err) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Token refresh failed", + }); + }); + return true; + }; + + handlers.googleCalendarEnsureWeeklyAlarm = (_req, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void ensureWeeklySyncAlarm() + .then(() => sendResponse({ success: true })) + .catch((err) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Could not schedule weekly sync", + }); + }); + return true; + }; + + handlers.googleCalendarUpdateSyncSettings = (request, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void (async () => { + const body = request as { + syncWeeksAhead?: number; + autoSyncWeekly?: boolean; + }; + const patch: Record = {}; + if (body.syncWeeksAhead != null) { + patch.syncWeeksAhead = clampSyncWeeks(body.syncWeeksAhead); + } + if (body.autoSyncWeekly != null) { + patch.autoSyncWeekly = !!body.autoSyncWeekly; + } + if (Object.keys(patch).length > 0) { + await writeSharedCalendarSyncSettings(patch); + } + await ensureWeeklySyncAlarm(); + sendResponse({ success: true, ...(await getGoogleCalendarStatus()) }); + })().catch((err) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Could not update sync settings", + }); + }); + return true; + }; +} + +export { initCalendarBackground as initGoogleCalendarBackground } from "./calendarWeekly"; diff --git a/src/background/outlookCalendar.ts b/src/background/outlookCalendar.ts new file mode 100644 index 00000000..bbbd96c8 --- /dev/null +++ b/src/background/outlookCalendar.ts @@ -0,0 +1,302 @@ +import browser from "webextension-polyfill"; +import { + OUTLOOK_AUTH_URL, + OUTLOOK_CALENDAR_OAUTH_CALLBACK, + OUTLOOK_CALENDAR_SCOPE, + OUTLOOK_OAUTH_CLIENT_ID, + isOutlookCalendarConfigured, + outlookOAuthRedirectUriHint, +} from "@/config/outlookCalendar"; +import { + exchangeOutlookCodeViaAccounts, + refreshOutlookTokenViaAccounts, +} from "@/seqta/utils/outlookCalendar/accountsToken"; +import { + clearOutlookCalendarState, + readOutlookCalendarState, + writeOutlookCalendarState, +} from "@/seqta/utils/outlookCalendar/storage"; +import type { OutlookCalendarStatus } from "@/seqta/utils/outlookCalendar/types"; +import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; +import { ensureWeeklySyncAlarm } from "./calendarWeekly"; + +function base64UrlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +async function sha256(input: string): Promise { + const data = new TextEncoder().encode(input); + return crypto.subtle.digest("SHA-256", data); +} + +function randomVerifier(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes.buffer); +} + +async function pkceChallenge(verifier: string): Promise { + return base64UrlEncode(await sha256(verifier)); +} + +function parseRedirectCode(responseUrl: string): string { + const url = new URL(responseUrl); + const err = url.searchParams.get("error"); + if (err) throw new Error(url.searchParams.get("error_description") ?? err); + const code = url.searchParams.get("code"); + if (!code) throw new Error("Microsoft sign-in did not return an authorization code."); + return code; +} + +const OAUTH_CALLBACK_PREFIX = OUTLOOK_CALENDAR_OAUTH_CALLBACK; +const OAUTH_TIMEOUT_MS = 10 * 60 * 1000; + +function isOAuthCallbackUrl(url: string): boolean { + if (!url.startsWith(OAUTH_CALLBACK_PREFIX)) return false; + const parsed = new URL(url); + return parsed.searchParams.has("code") || parsed.searchParams.has("error"); +} + +function waitForOAuthCallback(authTabId: number): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + cleanup(); + reject(new Error("Microsoft sign-in timed out. Close the tab and try again.")); + }, OAUTH_TIMEOUT_MS); + + const cleanup = () => { + clearTimeout(timeoutId); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.tabs.onRemoved.removeListener(onRemoved); + }; + + const finishFromUrl = (url: string, tabId: number) => { + if (!isOAuthCallbackUrl(url)) return false; + cleanup(); + void browser.tabs.remove(tabId).catch(() => {}); + resolve(url); + return true; + }; + + const onRemoved = (tabId: number) => { + if (tabId !== authTabId) return; + cleanup(); + reject(new Error("Microsoft sign-in was cancelled.")); + }; + + const onUpdated = ( + tabId: number, + changeInfo: browser.Tabs.OnUpdatedChangeInfoType, + tab: browser.Tabs.Tab, + ) => { + if (tabId !== authTabId) return; + const url = + changeInfo.url ?? (changeInfo.status === "complete" ? tab.url : undefined); + if (url) finishFromUrl(url, tabId); + }; + + browser.tabs.onUpdated.addListener(onUpdated); + browser.tabs.onRemoved.addListener(onRemoved); + + void browser.tabs + .get(authTabId) + .then((tab) => { + if (tab.url) finishFromUrl(tab.url, authTabId); + }) + .catch(() => {}); + }); +} + +async function openOAuthTab(authUrl: string): Promise { + const tab = await browser.tabs.create({ url: authUrl, active: true }); + if (tab.id === undefined) { + throw new Error("Could not open Microsoft sign-in tab."); + } + return waitForOAuthCallback(tab.id); +} + +async function getValidAccessToken(): Promise { + const state = await readOutlookCalendarState(); + const now = Date.now(); + if (state.accessToken && state.expiresAt && state.expiresAt > now + 60_000) { + return state.accessToken; + } + if (!state.refreshToken) { + throw new Error("Not connected to Outlook Calendar."); + } + const refreshed = await refreshOutlookTokenViaAccounts(state.refreshToken); + const expiresAt = refreshed.expires_in + ? Date.now() + refreshed.expires_in * 1000 + : Date.now() + 3_600_000; + await writeOutlookCalendarState({ + accessToken: refreshed.access_token, + refreshToken: refreshed.refresh_token ?? state.refreshToken, + expiresAt, + }); + return refreshed.access_token; +} + +async function connectOutlookCalendar(): Promise { + if (!isOutlookCalendarConfigured()) { + return { + success: false, + configured: false, + error: "Outlook Calendar is not configured in this extension build.", + }; + } + + const redirectUri = OUTLOOK_CALENDAR_OAUTH_CALLBACK; + const verifier = randomVerifier(); + const challenge = await pkceChallenge(verifier); + const authUrl = new URL(OUTLOOK_AUTH_URL); + authUrl.searchParams.set("client_id", OUTLOOK_OAUTH_CLIENT_ID); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("scope", OUTLOOK_CALENDAR_SCOPE); + authUrl.searchParams.set("response_mode", "query"); + authUrl.searchParams.set("prompt", "consent"); + authUrl.searchParams.set("code_challenge", challenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + + let responseUrl: string; + try { + responseUrl = await openOAuthTab(authUrl.toString()); + } catch (err) { + const message = err instanceof Error ? err.message : "Microsoft sign-in failed"; + const mismatch = + /redirect_uri|invalid_request|AADSTS50011/i.test(message) || + /redirect_uri|invalid_request|AADSTS50011/i.test(String(err)); + return { + success: false, + configured: true, + error: mismatch + ? `Microsoft redirect URI mismatch. ${outlookOAuthRedirectUriHint()}` + : message.includes("cancel") + ? "Microsoft sign-in was cancelled." + : message, + }; + } + + try { + const code = parseRedirectCode(responseUrl); + const tokens = await exchangeOutlookCodeViaAccounts(code, redirectUri, verifier); + const expiresAt = tokens.expires_in + ? Date.now() + tokens.expires_in * 1000 + : Date.now() + 3_600_000; + + const existing = await readOutlookCalendarState(); + await writeOutlookCalendarState({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? existing.refreshToken, + expiresAt, + connectedAt: Date.now(), + }); + + await ensureWeeklySyncAlarm(); + + return { success: true, configured: true, connected: true }; + } catch (err) { + return { + success: false, + configured: true, + error: err instanceof Error ? err.message : "Microsoft sign-in failed", + }; + } +} + +async function getOutlookCalendarStatus(): Promise { + const state = await readOutlookCalendarState(); + return { + configured: isOutlookCalendarConfigured(), + connected: !!(state.refreshToken || state.accessToken), + lastSyncAt: state.lastSyncAt, + lastSyncOrigin: state.lastSyncOrigin, + }; +} + +export async function handleOutlookCalendarConnect(): Promise { + return connectOutlookCalendar(); +} + +export async function handleOutlookCalendarDisconnect(): Promise<{ success: boolean }> { + await clearOutlookCalendarState(); + await ensureWeeklySyncAlarm(); + return { success: true }; +} + +export async function handleOutlookCalendarStatus(): Promise { + return getOutlookCalendarStatus(); +} + +export function registerOutlookCalendarMessageHandlers( + handlers: Record< + string, + ( + request: unknown, + sendResponse: (response?: unknown) => void, + sender?: browser.Runtime.MessageSender, + ) => boolean | void + >, + isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean, +): void { + const rejectUntrusted = ( + sendResponse: (response?: unknown) => void, + sender?: browser.Runtime.MessageSender, + ): boolean => { + if (isTrustedSender(sender)) return false; + sendResponse({ success: false, error: "Unauthorized sender" }); + return true; + }; + + handlers.outlookCalendarConnect = (_req, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void handleOutlookCalendarConnect() + .then(sendResponse) + .catch((err) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Microsoft sign-in failed", + }); + }); + return true; + }; + + handlers.outlookCalendarDisconnect = (_req, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void handleOutlookCalendarDisconnect() + .then(sendResponse) + .catch((err) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Disconnect failed", + }); + }); + return true; + }; + + handlers.outlookCalendarStatus = (_req, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void handleOutlookCalendarStatus() + .then(sendResponse) + .catch(() => { + sendResponse({ configured: isOutlookCalendarConfigured(), connected: false }); + }); + return true; + }; + + handlers.outlookCalendarGetAccessToken = (_req, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void getValidAccessToken() + .then((accessToken) => sendResponse({ success: true, accessToken })) + .catch((err) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Token refresh failed", + }); + }); + return true; + }; +} diff --git a/src/config/googleCalendar.ts b/src/config/googleCalendar.ts new file mode 100644 index 00000000..9b669dc7 --- /dev/null +++ b/src/config/googleCalendar.ts @@ -0,0 +1,46 @@ +/** + * 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"; + +/** Default weeks of timetable to sync forward (from start of current week). */ +export const GOOGLE_CALENDAR_SYNC_WEEKS = 12; +export const GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT = GOOGLE_CALENDAR_SYNC_WEEKS; +export const GOOGLE_CALENDAR_SYNC_WEEKS_MIN = 1; +export const GOOGLE_CALENDAR_SYNC_WEEKS_MAX = 52; + +export function isGoogleCalendarConfigured(): boolean { + return GOOGLE_OAUTH_CLIENT_ID.length > 0; +} + +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/config/outlookCalendar.ts b/src/config/outlookCalendar.ts new file mode 100644 index 00000000..bac80183 --- /dev/null +++ b/src/config/outlookCalendar.ts @@ -0,0 +1,43 @@ +/** + * Outlook Calendar OAuth — public client config (extension). + * Client secret and token exchange live on accounts.betterseqta.org. + * See docs/OUTLOOK_CALENDAR_ACCOUNTS_CALLBACK.md + */ + +import { ACCOUNTS_BASE } from "@/config/googleCalendar"; + +const HARDCODED_OUTLOOK_OAUTH_CLIENT_ID = + "0b55168c-916c-4323-8f67-b3dd30af3c9e"; + +const envClientId = + typeof __OUTLOOK_OAUTH_CLIENT_ID__ !== "undefined" ? __OUTLOOK_OAUTH_CLIENT_ID__ : ""; + +export const OUTLOOK_OAUTH_CLIENT_ID: string = + envClientId.trim() || HARDCODED_OUTLOOK_OAUTH_CLIENT_ID.trim(); + +/** Must match Azure app registration + accounts callback route exactly. */ +export const OUTLOOK_CALENDAR_OAUTH_CALLBACK = `${ACCOUNTS_BASE}/auth/microsoft/calendar/callback`; + +export const OUTLOOK_CALENDAR_TOKEN_URL = `${ACCOUNTS_BASE}/api/bsplus/microsoft/calendar/token`; +export const OUTLOOK_CALENDAR_REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/microsoft/calendar/refresh`; + +/** Delegated Graph scopes for create/update/delete calendar events. */ +export const OUTLOOK_CALENDAR_SCOPE = "offline_access Calendars.ReadWrite User.Read"; + +export const OUTLOOK_AUTH_URL = + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; + +export const OUTLOOK_GRAPH_API = "https://graph.microsoft.com/v1.0"; + +export const BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY = "BetterSEQTA+"; + +export function isOutlookCalendarConfigured(): boolean { + return OUTLOOK_OAUTH_CLIENT_ID.length > 0; +} + +export const OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT = + "Outlook Calendar connect requires accounts.betterseqta.org — see docs/OUTLOOK_CALENDAR_ACCOUNTS_CALLBACK.md"; + +export function outlookOAuthRedirectUriHint(): string { + return `Authorized redirect URI in Azure must be: ${OUTLOOK_CALENDAR_OAUTH_CALLBACK}`; +} diff --git a/src/env.d.ts b/src/env.d.ts index b3a32339..9e093d36 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -2,3 +2,5 @@ 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; +declare const __OUTLOOK_OAUTH_CLIENT_ID__: string; 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 index 3968fe41..7496419f 100644 --- a/src/lib/extensionAssetUrl.ts +++ b/src/lib/extensionAssetUrl.ts @@ -1,9 +1,19 @@ import browser from "webextension-polyfill"; -/** Vite `?url` imports are already absolute extension URLs in production bundles. */ +/** Vite asset imports are often already absolute extension URLs in production bundles. */ export function resolveExtensionAssetUrl(importedUrl: string): string { - if (/^(chrome-extension|moz-extension|https?):/.test(importedUrl)) { + 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 9ecc5e86..166443cb 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -15,17 +15,27 @@ "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", "alarms"], + "host_permissions": [ + "https://newsapi.org/", + "https://betterseqta.org/", + "https://accounts.betterseqta.org/", + "https://www.googleapis.com/", + "https://oauth2.googleapis.com/", + "https://graph.microsoft.com/", + "https://login.microsoftonline.com/", + "*://*/*" + ], "background": { "service_worker": "background.ts" }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org https://www.googleapis.com https://oauth2.googleapis.com https://graph.microsoft.com https://login.microsoftonline.com" }, "content_scripts": [ { "matches": ["*://*/*"], + "exclude_matches": ["*://accounts.betterseqta.org/*"], "js": ["SEQTA.ts"], "run_at": "document_start" } @@ -39,6 +49,7 @@ "resources/pdfjs/pdf.legacy.min.mjs", "resources/ort/*", "assets/*.css" + "assets/*" ], "matches": ["*://*/*"] } diff --git a/src/plugins/built-in/gradeAnalytics/GradeRangeSlider.svelte b/src/plugins/built-in/gradeAnalytics/GradeRangeSlider.svelte index 687e4c26..18042b3e 100644 --- a/src/plugins/built-in/gradeAnalytics/GradeRangeSlider.svelte +++ b/src/plugins/built-in/gradeAnalytics/GradeRangeSlider.svelte @@ -1,4 +1,6 @@
@@ -59,8 +113,8 @@ {min} {max} {step} - value={value[0]} - oninput={onMinInput} + value={visual[0]} + oninput={(e) => onInput(e, "min", false)} onpointerdown={() => (dragging = "min")} onpointerup={() => (dragging = null)} onpointercancel={() => (dragging = null)} @@ -79,8 +133,8 @@ {min} {max} {step} - value={value[1]} - oninput={onMaxInput} + value={visual[1]} + oninput={(e) => onInput(e, "max", false)} onpointerdown={() => (dragging = "max")} onpointerup={() => (dragging = null)} onpointercancel={() => (dragging = null)} @@ -94,15 +148,42 @@ aria-valuenow={value[1]} />
- - {value[0]}% – {value[1]}% - + +
+ + onInput(e, "min", true)} + placeholder={min} + min={min} + max={max} + step={step} + /> + % + + + + onInput(e, "max", true)} + placeholder={max} + min={min} + max={max} + step={step} + /> + % + +
diff --git a/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte b/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte new file mode 100644 index 00000000..3a6f7076 --- /dev/null +++ b/src/plugins/built-in/timetable/CalendarDisconnectModal.svelte @@ -0,0 +1,142 @@ + + +{#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..63f8809b --- /dev/null +++ b/src/plugins/built-in/timetable/CalendarSyncControl.svelte @@ -0,0 +1,946 @@ + + +
+ + + {#if menuOpen} + + {/if} + + { + if (busy?.phase !== "delete") showDeleteEvents = false; + }} + onConfirm={confirmDeleteEvents} + /> + + { + if (busy?.phase !== "disconnect") showDisconnect = false; + }} + onConfirm={confirmDisconnect} + /> + + {#if toast} +
+ {toast.message} +
+ {/if} +
+ + diff --git a/src/plugins/built-in/timetable/CalendarSyncProgress.svelte b/src/plugins/built-in/timetable/CalendarSyncProgress.svelte new file mode 100644 index 00000000..7c7066df --- /dev/null +++ b/src/plugins/built-in/timetable/CalendarSyncProgress.svelte @@ -0,0 +1,67 @@ + + +{#if progress && progress.phase !== "done"} +
+
{progress.message}
+ + {#if progress.total > 0} +
{progress.current} / {progress.total}
+ {/if} +
+{/if} + + diff --git a/src/plugins/built-in/timetable/OutlookCalendarIcon.svelte b/src/plugins/built-in/timetable/OutlookCalendarIcon.svelte new file mode 100644 index 00000000..23bb5e1f --- /dev/null +++ b/src/plugins/built-in/timetable/OutlookCalendarIcon.svelte @@ -0,0 +1,231 @@ + diff --git a/src/plugins/built-in/timetable/calendarSyncHost.css b/src/plugins/built-in/timetable/calendarSyncHost.css new file mode 100644 index 00000000..c27f092d --- /dev/null +++ b/src/plugins/built-in/timetable/calendarSyncHost.css @@ -0,0 +1,24 @@ +.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; + font-family: var(--bsplus-cal-font-family, var(--betterseqta-font-family, Rubik), sans-serif); +} diff --git a/src/plugins/built-in/timetable/calendarSyncTheme.ts b/src/plugins/built-in/timetable/calendarSyncTheme.ts new file mode 100644 index 00000000..a8320b20 --- /dev/null +++ b/src/plugins/built-in/timetable/calendarSyncTheme.ts @@ -0,0 +1,88 @@ +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; +import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor"; +import { ensureFontLoaded } from "@/seqta/ui/fonts/Manager"; +import { getFontPreset } from "@/seqta/ui/fonts/presets"; + +export const CALENDAR_THEME_CSS_VARS = [ + "--better-main", + "--better-pale", + "--better-light", + "--text-color", + "--background-primary", + "--background-secondary", + "--text-primary", + "--theme-offset-bg", + "--better-sub", +] as const; + +const ACCENT_CSS_VARS = [ + "--better-main", + "--accent-color-value", + "--accentColor", + "--colour-betterseqta-blue", +] as const; + +export function isCalendarDarkMode(): boolean { + return !!settingsState.DarkMode || document.documentElement.classList.contains("dark"); +} + +function resolvePageAccentColor(): string { + const computed = getComputedStyle(document.documentElement); + for (const name of ACCENT_CSS_VARS) { + const solid = extractSolidColor(computed.getPropertyValue(name)); + if (solid) return solid; + } + const fromSettings = settingsState.selectedColor?.trim(); + if (fromSettings) { + const solid = extractSolidColor(fromSettings); + if (solid) return solid; + } + return "#3b82f6"; +} + +/** Sync extension theme (including dark mode) onto a calendar UI host or portaled menu. */ +export function syncCalendarSyncTheme(target: HTMLElement): void { + const computed = getComputedStyle(document.documentElement); + const dark = isCalendarDarkMode(); + const fontPreset = getFontPreset(settingsState.selectedFont); + + ensureFontLoaded(fontPreset); + target.style.setProperty("--bsplus-cal-font-family", fontPreset.stack); + + for (const name of CALENDAR_THEME_CSS_VARS) { + const value = + document.documentElement.style.getPropertyValue(name).trim() || + computed.getPropertyValue(name).trim(); + if (value) target.style.setProperty(name, value); + } + + const accent = resolvePageAccentColor(); + target.style.setProperty("--bsplus-cal-accent", accent); + target.style.setProperty("--better-main", accent); + target.classList.toggle("dark", dark); + + const textPrimary = + computed.getPropertyValue("--text-primary").trim() || + computed.getPropertyValue("--text-color").trim(); + const bgPrimary = + computed.getPropertyValue("--background-primary").trim() || + computed.getPropertyValue("--background-secondary").trim() || + computed.getPropertyValue("--theme-offset-bg").trim(); + + target.style.setProperty( + "--bsplus-cal-text", + textPrimary || (dark ? "#f4f4f5" : "#18181b"), + ); + target.style.setProperty( + "--bsplus-cal-surface", + bgPrimary || (dark ? "#27272a" : "#ffffff"), + ); + target.style.setProperty( + "--bsplus-cal-surface-muted", + dark ? "#3f3f46" : "color-mix(in srgb, var(--bsplus-cal-text) 5%, var(--bsplus-cal-surface))", + ); + target.style.setProperty( + "--bsplus-cal-border", + dark ? "color-mix(in srgb, #ffffff 14%, transparent)" : "color-mix(in srgb, var(--bsplus-cal-text) 12%, transparent)", + ); +} diff --git a/src/plugins/built-in/timetable/calendarSyncUi.ts b/src/plugins/built-in/timetable/calendarSyncUi.ts new file mode 100644 index 00000000..bb39c2a6 --- /dev/null +++ b/src/plugins/built-in/timetable/calendarSyncUi.ts @@ -0,0 +1,55 @@ +import { mount, unmount } from "svelte"; +import CalendarSyncControl from "./CalendarSyncControl.svelte"; +import { syncCalendarSyncTheme } from "./calendarSyncTheme"; +import { registerCalendarContentHandlers } from "@/seqta/utils/googleCalendar/calendarSyncListener"; +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(); + registerCalendarContentHandlers(); + + 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 4f0fe808..88e089d1 100644 --- a/src/plugins/built-in/timetable/index.ts +++ b/src/plugins/built-in/timetable/index.ts @@ -4,6 +4,7 @@ import { attachTimetableColorisRecovery } from "@/seqta/utils/timetableColoris"; import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat"; import { waitForElm } from "@/seqta/utils/waitForElm"; import { verboseLog } from "@/utils/verboseLog"; +import { mountGoogleCalendarButton, unmountGoogleCalendarButton } from "./calendarSyncUi"; const timetablePlugin: Plugin<{}, {}> = { id: "timetable", @@ -28,6 +29,7 @@ const timetablePlugin: Plugin<{}, {}> = { const hideControls = document.querySelector(".timetable-hide-controls"); if (hideControls) hideControls.remove(); + unmountGoogleCalendarButton(); resetTimetableStyles(); } }; @@ -85,6 +87,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 1271a9b0..3dffaa97 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -87,7 +87,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"); diff --git a/src/seqta/utils/calendarSync/lessonSyncShared.ts b/src/seqta/utils/calendarSync/lessonSyncShared.ts new file mode 100644 index 00000000..07e3b94f --- /dev/null +++ b/src/seqta/utils/calendarSync/lessonSyncShared.ts @@ -0,0 +1,236 @@ +import { verboseLog } from "@/utils/verboseLog"; +import { + getStoredEventId, + lessonDateFromSeqtaKey, + normalizeEventMapEntry, +} from "@/seqta/utils/googleCalendar/eventMapEntry"; +import { + isDateInRange, + syncWindowRange, +} from "@/seqta/utils/googleCalendar/syncDateRange"; +import type { + GoogleCalendarSyncOptions, + GoogleCalendarSyncProgress, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; + +export const EVENT_MAP_PERSIST_EVERY = 10; + +export type EventMapRecord = Record; + +export type MappedLessonEvent = { + seqtaKey: string; + startDateTime: string; +}; + +export function reportSyncProgress( + onProgress: GoogleCalendarSyncOptions["onProgress"], + progress: GoogleCalendarSyncProgress, +) { + onProgress?.(progress); +} + +export function lessonDateForEvent(startDateTime: string, seqtaKey: string): string { + return startDateTime.slice(0, 10) || lessonDateFromSeqtaKey(seqtaKey) || ""; +} + +export function originEventMapEntries( + eventMap: EventMapRecord, + origin: string, +): Array<[string, string]> { + const prefix = `${origin}::`; + const entries: Array<[string, string]> = []; + for (const [key, value] of Object.entries(eventMap)) { + if (!key.startsWith(prefix)) continue; + const id = getStoredEventId(value); + if (id) entries.push([key, id]); + } + return entries; +} + +function shouldPruneEntry( + mode: "full" | "incremental", + entry: { id: string; date: string }, + mapKey: string, + window: ReturnType, + currentMapKeys: Set, +): boolean { + if (mode === "incremental") return false; + if (entry.date) return !isDateInRange(entry.date, window); + return !currentMapKeys.has(mapKey); +} + +export function entriesToPrune( + eventMap: EventMapRecord, + origin: string, + mode: "full" | "incremental", + weeksAhead: number, + currentMapKeys: Set, +): Array<[string, string]> { + const window = syncWindowRange(weeksAhead); + const prefix = `${origin}::`; + const entries: Array<[string, string]> = []; + + for (const [mapKey, raw] of Object.entries(eventMap)) { + if (!mapKey.startsWith(prefix)) continue; + const entry = normalizeEventMapEntry(raw); + if (!entry) continue; + if (shouldPruneEntry(mode, entry, mapKey, window, currentMapKeys)) { + entries.push([mapKey, entry.id]); + } + } + + return entries; +} + +export function notConfiguredSyncResult(error: string): GoogleCalendarSyncResult { + return { success: false, configured: false, error }; +} + +export function notConnectedSyncResult(error: string): GoogleCalendarSyncResult { + return { success: false, configured: true, connected: false, error }; +} + +export function emptyLessonsSyncResult(): GoogleCalendarSyncResult { + return { + success: false, + configured: true, + connected: true, + error: "No timetable classes found to sync for the selected range.", + }; +} + +export function buildLessonSyncResult( + created: number, + updated: number, + deleted: number, + failed: number, + lastSyncAt: number, +): GoogleCalendarSyncResult { + return { + success: failed === 0, + configured: true, + connected: true, + created, + updated, + deleted, + skipped: 0, + failed, + lastSyncAt, + error: + failed > 0 + ? `Synced with ${failed} error${failed === 1 ? "" : "s"}. Check the console for details.` + : undefined, + }; +} + +export async function persistFinalSyncState( + writeState: (patch: { + eventMap: EventMapRecord; + lastSyncAt: number; + lastSyncOrigin: string; + }) => Promise, + eventMap: EventMapRecord, + lastSyncAt: number, + origin: string, + staleDeleted: number, + staleEntryCount: number, + eventCount: number, +): Promise { + if (staleDeleted > 0 || staleEntryCount > 0 || eventCount > 0) { + await writeState({ eventMap, lastSyncAt, lastSyncOrigin: origin }); + } +} + +type UpsertLessonEventsParams = { + events: TEvent[]; + eventMap: EventMapRecord; + origin: string; + staleEntryCount: number; + totalSteps: number; + lastSyncAt: number; + initialFailed: number; + getAccessToken: () => Promise; + mapKey: (origin: string, seqtaKey: string) => string; + upsert: ( + accessToken: string, + existingId: string | undefined, + event: TEvent, + refreshAccessToken: () => Promise, + ) => Promise; + writeState: (patch: { + eventMap: EventMapRecord; + lastSyncAt: number; + lastSyncOrigin: string; + }) => Promise; + onProgress?: GoogleCalendarSyncOptions["onProgress"]; + logLabel: string; +}; + +export async function upsertLessonEvents( + params: UpsertLessonEventsParams, +): Promise<{ created: number; updated: number; failed: number; accessToken: string }> { + const { + events, + eventMap, + origin, + staleEntryCount, + totalSteps, + lastSyncAt, + initialFailed, + getAccessToken, + mapKey, + upsert, + writeState, + onProgress, + logLabel, + } = params; + + let accessToken = await getAccessToken(); + let created = 0; + let updated = 0; + let failed = initialFailed; + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + const key = mapKey(origin, event.seqtaKey); + const existingId = getStoredEventId(eventMap[key]); + const progressCurrent = staleEntryCount + i + 1; + const progressMessage = `Syncing events (${i + 1}/${events.length})…`; + + try { + const remoteId = await upsert(accessToken, existingId, event, async () => { + accessToken = await getAccessToken(); + return accessToken; + }); + if (existingId) updated += 1; + else created += 1; + eventMap[key] = { + id: remoteId, + date: lessonDateForEvent(event.startDateTime, event.seqtaKey), + }; + + reportSyncProgress(onProgress, { + phase: "upserting", + current: progressCurrent, + total: totalSteps, + message: progressMessage, + }); + + if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) { + await writeState({ eventMap, lastSyncAt, lastSyncOrigin: origin }); + } + } catch (err) { + verboseLog(`[BetterSEQTA+] ${logLabel} event sync failed:`, err); + failed += 1; + reportSyncProgress(onProgress, { + phase: "upserting", + current: progressCurrent, + total: totalSteps, + message: progressMessage, + }); + } + } + + return { created, updated, failed, accessToken }; +} diff --git a/src/seqta/utils/calendarSync/settings.ts b/src/seqta/utils/calendarSync/settings.ts new file mode 100644 index 00000000..ab6e1fd2 --- /dev/null +++ b/src/seqta/utils/calendarSync/settings.ts @@ -0,0 +1,65 @@ +import { + GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT, + GOOGLE_CALENDAR_SYNC_WEEKS_MAX, + GOOGLE_CALENDAR_SYNC_WEEKS_MIN, +} from "@/config/googleCalendar"; +import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage"; +import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; +import { + readSharedCalendarSyncSettings, + WEEKLY_SYNC_INTERVAL_MS, + writeSharedCalendarSyncSettings, +} from "./sharedSettings"; + +export { CALENDAR_WEEKLY_ALARM, WEEKLY_SYNC_INTERVAL_MS } from "./sharedSettings"; + +export function clampSyncWeeks(weeks: number): number { + if (!Number.isFinite(weeks)) return GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT; + return Math.min( + GOOGLE_CALENDAR_SYNC_WEEKS_MAX, + Math.max(GOOGLE_CALENDAR_SYNC_WEEKS_MIN, Math.round(weeks)), + ); +} + +export async function getSyncWeeksAhead(): Promise { + const settings = await readSharedCalendarSyncSettings(); + return clampSyncWeeks(settings.syncWeeksAhead ?? GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT); +} + +export async function getAutoSyncWeekly(): Promise { + const settings = await readSharedCalendarSyncSettings(); + return settings.autoSyncWeekly !== false; +} + +async function isAnyCalendarConnected(): Promise { + const [google, outlook] = await Promise.all([ + readGoogleCalendarState(), + readOutlookCalendarState(), + ]); + return !!( + google.refreshToken || + google.accessToken || + outlook.refreshToken || + outlook.accessToken + ); +} + +export async function shouldRunWeeklySync(): Promise { + const settings = await readSharedCalendarSyncSettings(); + if (settings.autoSyncWeekly === false) return false; + if (!(await isAnyCalendarConnected())) return false; + if (settings.pendingWeeklySync) return true; + const last = settings.lastWeeklySyncAt ?? 0; + return Date.now() - last >= WEEKLY_SYNC_INTERVAL_MS; +} + +export async function markWeeklySyncComplete(): Promise { + await writeSharedCalendarSyncSettings({ + lastWeeklySyncAt: Date.now(), + pendingWeeklySync: false, + }); +} + +export async function markWeeklySyncPending(): Promise { + await writeSharedCalendarSyncSettings({ pendingWeeklySync: true }); +} diff --git a/src/seqta/utils/calendarSync/sharedSettings.ts b/src/seqta/utils/calendarSync/sharedSettings.ts new file mode 100644 index 00000000..543728b3 --- /dev/null +++ b/src/seqta/utils/calendarSync/sharedSettings.ts @@ -0,0 +1,48 @@ +import browser from "webextension-polyfill"; +import { + GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT, +} from "@/config/googleCalendar"; +import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; + +export const BSPLUS_CALENDAR_SYNC_SETTINGS_KEY = "bsplus_calendar_sync_settings"; +export const CALENDAR_WEEKLY_ALARM = "bsplus_calendar_weekly"; +export const WEEKLY_SYNC_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; + +export interface SharedCalendarSyncSettings { + syncWeeksAhead?: number; + autoSyncWeekly?: boolean; + lastWeeklySyncAt?: number; + pendingWeeklySync?: boolean; +} + +export async function readSharedCalendarSyncSettings(): Promise { + const got = await browser.storage.local.get(BSPLUS_CALENDAR_SYNC_SETTINGS_KEY); + const raw = got[BSPLUS_CALENDAR_SYNC_SETTINGS_KEY]; + const shared = + raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as SharedCalendarSyncSettings) + : {}; + + if (Object.keys(shared).length > 0) return shared; + + const legacy = await readGoogleCalendarState(); + return { + syncWeeksAhead: legacy.syncWeeksAhead, + autoSyncWeekly: legacy.autoSyncWeekly, + lastWeeklySyncAt: legacy.lastWeeklySyncAt, + pendingWeeklySync: legacy.pendingWeeklySync, + }; +} + +export async function writeSharedCalendarSyncSettings( + patch: Partial, +): Promise { + const current = await readSharedCalendarSyncSettings(); + const next = { ...current, ...patch }; + await browser.storage.local.set({ [BSPLUS_CALENDAR_SYNC_SETTINGS_KEY]: next }); + return next; +} + +export function defaultSyncWeeksAhead(): number { + return GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT; +} diff --git a/src/seqta/utils/calendarSync/weeklySyncMessages.ts b/src/seqta/utils/calendarSync/weeklySyncMessages.ts new file mode 100644 index 00000000..249be3a7 --- /dev/null +++ b/src/seqta/utils/calendarSync/weeklySyncMessages.ts @@ -0,0 +1,51 @@ +import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; +import { formatSyncResultMessage } from "@/seqta/utils/googleCalendar/syncRunner"; +import { formatOutlookSyncResultMessage } from "@/seqta/utils/outlookCalendar/syncRunner"; + +type ProviderCalendarState = { + refreshToken?: string; + accessToken?: string; +}; + +function isProviderConnected(state: ProviderCalendarState): boolean { + return Boolean(state.refreshToken || state.accessToken); +} + +function providerHadChanges(result: GoogleCalendarSyncResult): boolean { + return ( + (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0 + ); +} + +export function weeklySyncErrorMessage( + results: GoogleCalendarSyncResult[], +): string | undefined { + const failed = results.find((result) => !result.success); + if (!failed) return undefined; + return failed.error ?? "Weekly calendar sync failed."; +} + +export function formatWeeklySyncMessages( + google: ProviderCalendarState, + outlook: ProviderCalendarState, + results: GoogleCalendarSyncResult[], +): string[] { + const messages: string[] = []; + let index = 0; + + if (isProviderConnected(google)) { + const result = results[index++]; + if (result && providerHadChanges(result)) { + messages.push(formatSyncResultMessage(result)); + } + } + + if (isProviderConnected(outlook)) { + const result = results[index++]; + if (result && providerHadChanges(result)) { + messages.push(formatOutlookSyncResultMessage(result)); + } + } + + return messages; +} diff --git a/src/seqta/utils/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/calendarSyncListener.ts b/src/seqta/utils/googleCalendar/calendarSyncListener.ts new file mode 100644 index 00000000..e4d750eb --- /dev/null +++ b/src/seqta/utils/googleCalendar/calendarSyncListener.ts @@ -0,0 +1,84 @@ +import browser from "webextension-polyfill"; +import { + markWeeklySyncComplete, + shouldRunWeeklySync, +} from "@/seqta/utils/calendarSync/settings"; +import { + formatWeeklySyncMessages, + weeklySyncErrorMessage, +} from "@/seqta/utils/calendarSync/weeklySyncMessages"; +import { runGoogleCalendarSync } from "@/seqta/utils/googleCalendar/syncRunner"; +import { runOutlookCalendarSync } from "@/seqta/utils/outlookCalendar/syncRunner"; +import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; +import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage"; +import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; + +let listenerRegistered = false; + +async function runWeeklySyncForConnectedProviders(): Promise { + const [google, outlook] = await Promise.all([ + readGoogleCalendarState(), + readOutlookCalendarState(), + ]); + const results: GoogleCalendarSyncResult[] = []; + + if (google.refreshToken || google.accessToken) { + results.push(await runGoogleCalendarSync({ mode: "incremental", silent: true })); + } + if (outlook.refreshToken || outlook.accessToken) { + results.push(await runOutlookCalendarSync({ mode: "incremental", silent: true })); + } + + if (results.some((r) => r.success)) { + await markWeeklySyncComplete(); + } + + return results; +} + +export function registerCalendarContentHandlers(): void { + if (listenerRegistered) return; + listenerRegistered = true; + + browser.runtime.onMessage.addListener((request, _sender, sendResponse) => { + if (request?.type === "calendarRunWeeklySync" || request?.type === "googleCalendarRunWeeklySync") { + void runWeeklySyncForConnectedProviders() + .then((results) => sendResponse({ success: true, results })) + .catch((err: unknown) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Weekly sync failed", + }); + }); + return true; + } + return false; + }); +} + +export async function maybeRunDueWeeklySync( + onComplete?: (message: string, isError?: boolean) => void, +): Promise { + if (!(await shouldRunWeeklySync())) return; + + const [google, outlook] = await Promise.all([ + readGoogleCalendarState(), + readOutlookCalendarState(), + ]); + const results = await runWeeklySyncForConnectedProviders(); + if (!onComplete) return; + + const errorMessage = weeklySyncErrorMessage(results); + if (errorMessage) { + onComplete(errorMessage, true); + return; + } + + const messages = formatWeeklySyncMessages(google, outlook, results); + if (messages.length > 0) { + onComplete(messages.join(" ")); + } +} + +/** @deprecated use registerCalendarContentHandlers */ +export const registerGoogleCalendarContentHandlers = registerCalendarContentHandlers; diff --git a/src/seqta/utils/googleCalendar/eventMapEntry.ts b/src/seqta/utils/googleCalendar/eventMapEntry.ts new file mode 100644 index 00000000..3428aa4c --- /dev/null +++ b/src/seqta/utils/googleCalendar/eventMapEntry.ts @@ -0,0 +1,29 @@ +export interface GoogleCalendarEventMapEntry { + id: string; + date: string; +} + +export function normalizeEventMapEntry( + value: string | GoogleCalendarEventMapEntry | undefined, +): GoogleCalendarEventMapEntry | undefined { + if (value == null) return undefined; + if (typeof value === "string") return { id: value, date: "" }; + if (typeof value.id === "string" && value.id.length > 0) { + return { id: value.id, date: value.date ?? "" }; + } + return undefined; +} + +export function getStoredEventId( + value: string | GoogleCalendarEventMapEntry | undefined, +): string | undefined { + return normalizeEventMapEntry(value)?.id; +} + +export function lessonDateFromSeqtaKey(seqtaKey: string): string | undefined { + const parts = seqtaKey.split(":"); + for (const part of parts) { + if (/^\d{4}-\d{2}-\d{2}$/.test(part)) return part; + } + return undefined; +} diff --git a/src/seqta/utils/googleCalendar/eventMapper.test.ts b/src/seqta/utils/googleCalendar/eventMapper.test.ts 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..ffbc0959 --- /dev/null +++ b/src/seqta/utils/googleCalendar/fetchTimetable.ts @@ -0,0 +1,80 @@ +import type { SyncDateRange } from "./syncDateRange"; +import { syncWindowRange } from "./syncDateRange"; +import type { SeqtaTimetableLesson } from "./types"; + +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 fetchTimetableLessons( + range: SyncDateRange, +): Promise { + const { from, until } = range; + + 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 : []; +} + +export async function fetchTimetableForSync(weeksAhead?: number): Promise { + return fetchTimetableLessons(syncWindowRange(weeksAhead)); +} + +export { syncWindowRange, trailingWeekRange, droppedWeekRange } from "./syncDateRange"; diff --git a/src/seqta/utils/googleCalendar/storage.ts b/src/seqta/utils/googleCalendar/storage.ts new file mode 100644 index 00000000..68b3bf60 --- /dev/null +++ b/src/seqta/utils/googleCalendar/storage.ts @@ -0,0 +1,44 @@ +import browser from "webextension-polyfill"; +import type { GoogleCalendarEventMapEntry } from "./eventMapEntry"; + +/** Never uploaded to BetterSEQTA Cloud (OAuth tokens + per-device event map). */ +export const BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY = "bsplus_google_calendar"; + +export interface GoogleCalendarStoredState { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + connectedAt?: number; + lastSyncAt?: number; + lastWeeklySyncAt?: number; + lastSyncOrigin?: string; + syncWeeksAhead?: number; + autoSyncWeekly?: boolean; + pendingWeeklySync?: boolean; + /** `${origin}::${seqtaKey}` → Google event id (+ lesson date when known) */ + eventMap?: Record; +} + +export async function readGoogleCalendarState(): Promise { + const got = await browser.storage.local.get(BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY); + const raw = got[BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY]; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return raw as GoogleCalendarStoredState; +} + +export async function writeGoogleCalendarState( + patch: Partial, +): Promise { + const current = await readGoogleCalendarState(); + const next: GoogleCalendarStoredState = { ...current, ...patch }; + await browser.storage.local.set({ [BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY]: next }); + return next; +} + +export async function clearGoogleCalendarState(): Promise { + await browser.storage.local.remove(BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY); +} + +export function eventMapKey(origin: string, seqtaKey: string): string { + return `${origin}::${seqtaKey}`; +} diff --git a/src/seqta/utils/googleCalendar/syncDateRange.test.ts b/src/seqta/utils/googleCalendar/syncDateRange.test.ts new file mode 100644 index 00000000..d6b21839 --- /dev/null +++ b/src/seqta/utils/googleCalendar/syncDateRange.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "@jest/globals"; +import { + droppedWeekRange, + syncWindowRange, + trailingWeekRange, +} from "./syncDateRange"; + +describe("syncDateRange", () => { + it("builds a 12-week rolling window from the current week", () => { + const range = syncWindowRange(12); + expect(range.from <= range.until).toBe(true); + + const start = new Date(`${range.from}T12:00:00`); + const end = new Date(`${range.until}T12:00:00`); + const days = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1; + expect(days).toBe(12 * 7); + }); + + it("places the trailing week at the end of the window", () => { + const window = syncWindowRange(12); + const trailing = trailingWeekRange(12); + expect(trailing.from >= window.from).toBe(true); + expect(trailing.until <= window.until).toBe(true); + }); + + it("places the dropped week before the window start", () => { + const window = syncWindowRange(12); + const dropped = droppedWeekRange(12); + expect(dropped.until < window.from).toBe(true); + }); +}); diff --git a/src/seqta/utils/googleCalendar/syncDateRange.ts b/src/seqta/utils/googleCalendar/syncDateRange.ts new file mode 100644 index 00000000..cd9b87ab --- /dev/null +++ b/src/seqta/utils/googleCalendar/syncDateRange.ts @@ -0,0 +1,45 @@ +import { + GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT, +} from "@/config/googleCalendar"; +import { toISODate, weekRangeContaining } from "@/seqta/utils/Loaders/engageParentTimetable"; + +export interface SyncDateRange { + from: string; + until: string; +} + +function parseLocalDate(iso: string): Date { + return new Date(`${iso}T12:00:00`); +} + +/** Full rolling sync window from the start of the current week. */ +export function syncWindowRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT): SyncDateRange { + const { from } = weekRangeContaining(new Date()); + const end = parseLocalDate(from); + end.setDate(end.getDate() + weeksAhead * 7 - 1); + return { from, until: toISODate(end) }; +} + +/** The trailing week at the end of the sync window (added each weekly roll). */ +export function trailingWeekRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT): SyncDateRange { + const { from: windowStart } = syncWindowRange(weeksAhead); + const start = parseLocalDate(windowStart); + start.setDate(start.getDate() + (weeksAhead - 1) * 7); + const end = new Date(start); + end.setDate(end.getDate() + 6); + return { from: toISODate(start), until: toISODate(end) }; +} + +/** The week that rolled off when the window advances (removed each weekly roll). */ +export function droppedWeekRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT): SyncDateRange { + const { from: windowStart } = syncWindowRange(weeksAhead); + const end = parseLocalDate(windowStart); + end.setDate(end.getDate() - 1); + const start = new Date(end); + start.setDate(start.getDate() - 6); + return { from: toISODate(start), until: toISODate(end) }; +} + +export function isDateInRange(date: string, range: SyncDateRange): boolean { + return date >= range.from && date <= range.until; +} diff --git a/src/seqta/utils/googleCalendar/syncEngine.test.ts b/src/seqta/utils/googleCalendar/syncEngine.test.ts new file mode 100644 index 00000000..595ef9d7 --- /dev/null +++ b/src/seqta/utils/googleCalendar/syncEngine.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { SeqtaTimetableLesson } from "./types"; + +jest.mock("@/config/googleCalendar", () => ({ + isGoogleCalendarConfigured: jest.fn(() => true), +})); + +jest.mock("@/utils/verboseLog", () => ({ + verboseLog: jest.fn(), +})); + +jest.mock("@/seqta/utils/googleCalendar/storage", () => ({ + eventMapKey: (origin: string, seqtaKey: string) => `${origin}::${seqtaKey}`, + readGoogleCalendarState: jest.fn(), + writeGoogleCalendarState: jest.fn(async (patch: unknown) => patch), +})); + +jest.mock("@/seqta/utils/calendarSync/settings", () => ({ + getSyncWeeksAhead: jest.fn(async () => 12), +})); + +jest.mock("@/seqta/utils/googleCalendar/upsertEvent", () => ({ + upsertGoogleCalendarEvent: jest.fn(), + deleteGoogleCalendarEvent: jest.fn(), +})); + +import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; +import { + deleteGoogleCalendarEvent, + upsertGoogleCalendarEvent, +} from "@/seqta/utils/googleCalendar/upsertEvent"; +import { deleteSyncedEventsFromGoogleCalendar, syncLessonsToGoogleCalendar } from "./syncEngine"; + +const ORIGIN = "https://school.seqta.com.au"; +const getAccessToken = async () => "test-token"; + +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("syncLessonsToGoogleCalendar", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(readGoogleCalendarState).mockResolvedValue({ + refreshToken: "refresh", + eventMap: { + [`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "google-existing", date: "2026-06-27" }, + [`${ORIGIN}::${ORIGIN}:cal:99999`]: { id: "google-stale", date: "2020-01-06" }, + }, + }); + jest.mocked(upsertGoogleCalendarEvent).mockResolvedValue("google-existing"); + jest.mocked(deleteGoogleCalendarEvent).mockResolvedValue(undefined); + }); + + it("updates existing events and removes stale tracked events on full sync", async () => { + const result = await syncLessonsToGoogleCalendar( + { origin: ORIGIN, lessons: [baseLesson], mode: "full" }, + getAccessToken, + ); + + expect(deleteGoogleCalendarEvent).toHaveBeenCalled(); + expect(upsertGoogleCalendarEvent).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + success: true, + created: 0, + updated: 1, + failed: 0, + }); + }); + + it("creates events that are not yet tracked", async () => { + jest.mocked(readGoogleCalendarState).mockResolvedValue({ + refreshToken: "refresh", + eventMap: {}, + }); + jest.mocked(upsertGoogleCalendarEvent).mockResolvedValue("google-new"); + + const result = await syncLessonsToGoogleCalendar( + { origin: ORIGIN, lessons: [baseLesson], mode: "full" }, + getAccessToken, + ); + + expect(deleteGoogleCalendarEvent).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + success: true, + created: 1, + updated: 0, + deleted: 0, + }); + }); + + it("does not delete events during incremental sync", async () => { + const result = await syncLessonsToGoogleCalendar( + { origin: ORIGIN, lessons: [baseLesson], mode: "incremental" }, + getAccessToken, + ); + + expect(deleteGoogleCalendarEvent).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + success: true, + deleted: 0, + }); + }); + + it("reports progress while syncing", async () => { + const progress: Array<{ phase: string; current: number; total: number }> = []; + await syncLessonsToGoogleCalendar( + { origin: ORIGIN, lessons: [baseLesson], mode: "full" }, + getAccessToken, + { + onProgress: (entry) => progress.push(entry), + }, + ); + + expect(progress.some((entry) => entry.phase === "upserting")).toBe(true); + expect(progress.at(-1)?.phase).toBe("done"); + }); +}); + +describe("deleteSyncedEventsFromGoogleCalendar", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(readGoogleCalendarState).mockResolvedValue({ + refreshToken: "refresh", + eventMap: { + [`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "google-1", date: "2026-06-27" }, + [`${ORIGIN}::${ORIGIN}:cal:99999`]: { id: "google-2", date: "2026-06-28" }, + "https://other.seqta.com.au::other:key": { id: "google-other", date: "2026-06-28" }, + }, + }); + jest.mocked(deleteGoogleCalendarEvent).mockResolvedValue(undefined); + }); + + it("deletes only events for the requested origin", async () => { + const result = await deleteSyncedEventsFromGoogleCalendar(ORIGIN, getAccessToken); + + expect(deleteGoogleCalendarEvent).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ + success: true, + deleted: 2, + failed: 0, + }); + }); +}); diff --git a/src/seqta/utils/googleCalendar/syncEngine.ts b/src/seqta/utils/googleCalendar/syncEngine.ts new file mode 100644 index 00000000..2517043a --- /dev/null +++ b/src/seqta/utils/googleCalendar/syncEngine.ts @@ -0,0 +1,250 @@ +import { verboseLog } from "@/utils/verboseLog"; +import { isGoogleCalendarConfigured } from "@/config/googleCalendar"; +import { googleApiEventBody, mapLessonsToGoogleEvents } from "@/seqta/utils/googleCalendar/eventMapper"; +import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; +import { + buildLessonSyncResult, + emptyLessonsSyncResult, + entriesToPrune, + EVENT_MAP_PERSIST_EVERY, + notConfiguredSyncResult, + notConnectedSyncResult, + originEventMapEntries, + persistFinalSyncState, + reportSyncProgress, + upsertLessonEvents, +} from "@/seqta/utils/calendarSync/lessonSyncShared"; +import { + eventMapKey, + readGoogleCalendarState, + writeGoogleCalendarState, +} from "@/seqta/utils/googleCalendar/storage"; +import type { + GoogleCalendarDeleteResult, + GoogleCalendarSyncOptions, + GoogleCalendarSyncRequest, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; +import { + deleteGoogleCalendarEvent, + upsertGoogleCalendarEvent, +} from "@/seqta/utils/googleCalendar/upsertEvent"; + +const CALENDAR_ID = "primary"; + +type DeleteTrackedEventsResult = { + deleted: number; + failed: number; +}; + +async function deleteTrackedEventsFromGoogle( + entries: Array<[string, string]>, + eventMap: Record, + getAccessToken: () => Promise, + persistProgress = false, + onProgress?: GoogleCalendarSyncOptions["onProgress"], + progressOffset = 0, + progressTotal = 0, +): Promise { + if (entries.length === 0) return { deleted: 0, failed: 0 }; + + let accessToken = await getAccessToken(); + let deleted = 0; + let failed = 0; + + for (let i = 0; i < entries.length; i++) { + const [mapKey, eventId] = entries[i]; + try { + await deleteGoogleCalendarEvent(accessToken, CALENDAR_ID, eventId, async () => { + accessToken = await getAccessToken(); + return accessToken; + }); + delete eventMap[mapKey]; + deleted += 1; + + reportSyncProgress(onProgress, { + phase: "deleting", + current: progressOffset + deleted + failed, + total: progressTotal, + message: `Removing old events (${deleted + failed}/${entries.length})…`, + }); + + if (persistProgress && (deleted + failed) % EVENT_MAP_PERSIST_EVERY === 0) { + await writeGoogleCalendarState({ eventMap }); + } + } catch (err) { + verboseLog("[BetterSEQTA+] Google Calendar event delete failed:", err); + failed += 1; + reportSyncProgress(onProgress, { + phase: "deleting", + current: progressOffset + deleted + failed, + total: progressTotal, + message: `Removing old events (${deleted + failed}/${entries.length})…`, + }); + } + } + + return { deleted, failed }; +} + +/** Runs in the content script tab so long syncs are not killed by the MV3 service worker. */ +export async function syncLessonsToGoogleCalendar( + request: GoogleCalendarSyncRequest, + getAccessToken: () => Promise, + options: GoogleCalendarSyncOptions = {}, +): Promise { + if (!isGoogleCalendarConfigured()) { + return notConfiguredSyncResult( + "Google Calendar is not configured in this extension build.", + ); + } + + const state = await readGoogleCalendarState(); + if (!state.refreshToken && !state.accessToken) { + return notConnectedSyncResult("Connect Google Calendar first."); + } + + const mode = request.mode ?? "full"; + const weeksAhead = request.weeksAhead ?? (await getSyncWeeksAhead()); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const events = mapLessonsToGoogleEvents(request.origin, request.lessons, timeZone); + + if (events.length === 0 && mode === "full") { + return emptyLessonsSyncResult(); + } + + reportSyncProgress(options.onProgress, { + phase: "preparing", + current: 0, + total: Math.max(events.length, 1), + message: mode === "incremental" ? "Preparing weekly sync…" : "Preparing sync…", + }); + + const eventMap = { ...(state.eventMap ?? {}) }; + const currentMapKeys = new Set(events.map((event) => eventMapKey(request.origin, event.seqtaKey))); + const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys); + const totalSteps = staleEntries.length + events.length; + const lastSyncAt = Date.now(); + + const staleResult = await deleteTrackedEventsFromGoogle( + staleEntries, + eventMap, + getAccessToken, + false, + options.onProgress, + 0, + totalSteps, + ); + + const upsertResult = await upsertLessonEvents({ + events, + eventMap, + origin: request.origin, + staleEntryCount: staleEntries.length, + totalSteps, + lastSyncAt, + initialFailed: staleResult.failed, + getAccessToken, + mapKey: eventMapKey, + upsert: (accessToken, existingId, event, refreshAccessToken) => + upsertGoogleCalendarEvent( + accessToken, + CALENDAR_ID, + existingId, + googleApiEventBody(event), + refreshAccessToken, + ), + writeState: writeGoogleCalendarState, + onProgress: options.onProgress, + logLabel: "Google Calendar", + }); + + await persistFinalSyncState( + writeGoogleCalendarState, + eventMap, + lastSyncAt, + request.origin, + staleResult.deleted, + staleEntries.length, + events.length, + ); + + reportSyncProgress(options.onProgress, { + phase: "done", + current: totalSteps, + total: totalSteps, + message: "Sync complete", + }); + + return buildLessonSyncResult( + upsertResult.created, + upsertResult.updated, + staleResult.deleted, + upsertResult.failed, + lastSyncAt, + ); +} + +/** Delete all tracked BetterSEQTA+ events for this SEQTA origin from Google Calendar. */ +export async function deleteSyncedEventsFromGoogleCalendar( + origin: string, + getAccessToken: () => Promise, + options: GoogleCalendarSyncOptions = {}, +): Promise { + if (!isGoogleCalendarConfigured()) { + return { + success: false, + configured: false, + error: "Google Calendar is not configured in this extension build.", + }; + } + + const state = await readGoogleCalendarState(); + if (!state.refreshToken && !state.accessToken) { + return { success: false, configured: true, connected: false, error: "Connect Google Calendar first." }; + } + + const entries = originEventMapEntries(state.eventMap ?? {}, origin); + if (entries.length === 0) { + return { success: true, configured: true, connected: true, deleted: 0, failed: 0 }; + } + + reportSyncProgress(options.onProgress, { + phase: "preparing", + current: 0, + total: entries.length, + message: "Preparing removal…", + }); + + const eventMap = { ...(state.eventMap ?? {}) }; + const { deleted, failed } = await deleteTrackedEventsFromGoogle( + entries, + eventMap, + getAccessToken, + true, + options.onProgress, + 0, + entries.length, + ); + + await writeGoogleCalendarState({ eventMap }); + + reportSyncProgress(options.onProgress, { + phase: "done", + current: entries.length, + total: entries.length, + message: "Removal complete", + }); + + return { + success: failed === 0, + configured: true, + connected: true, + deleted, + failed, + error: + failed > 0 + ? `Removed ${deleted} event${deleted === 1 ? "" : "s"} with ${failed} error${failed === 1 ? "" : "s"}.` + : undefined, + }; +} diff --git a/src/seqta/utils/googleCalendar/syncRunner.ts b/src/seqta/utils/googleCalendar/syncRunner.ts new file mode 100644 index 00000000..79b23ed9 --- /dev/null +++ b/src/seqta/utils/googleCalendar/syncRunner.ts @@ -0,0 +1,76 @@ +import browser from "webextension-polyfill"; +import { + fetchTimetableForSync, + fetchTimetableLessons, + trailingWeekRange, +} from "@/seqta/utils/googleCalendar/fetchTimetable"; +import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; +import { syncLessonsToGoogleCalendar } from "@/seqta/utils/googleCalendar/syncEngine"; +import type { + GoogleCalendarSyncOptions, + GoogleCalendarSyncProgress, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; + +export type GoogleCalendarRunMode = "full" | "incremental"; + +export interface RunGoogleCalendarSyncParams { + mode?: GoogleCalendarRunMode; + silent?: boolean; + onProgress?: (progress: GoogleCalendarSyncProgress) => void; +} + +async function getAccessTokenFromBackground(): Promise { + const res = (await browser.runtime.sendMessage({ + type: "googleCalendarGetAccessToken", + })) as { success?: boolean; accessToken?: string; error?: string }; + if (!res?.success || !res.accessToken) { + throw new Error(res?.error ?? "Could not get Google Calendar access token."); + } + return res.accessToken; +} + +export async function runGoogleCalendarSync( + params: RunGoogleCalendarSyncParams = {}, +): Promise { + const mode = params.mode ?? "full"; + const weeksAhead = await getSyncWeeksAhead(); + + params.onProgress?.({ + phase: "preparing", + current: 0, + total: 1, + message: mode === "incremental" ? "Fetching new week…" : "Fetching timetable…", + }); + + const lessons = + mode === "incremental" + ? await fetchTimetableLessons(trailingWeekRange(weeksAhead)) + : await fetchTimetableForSync(weeksAhead); + + const options: GoogleCalendarSyncOptions = { onProgress: params.onProgress }; + const result = await syncLessonsToGoogleCalendar( + { + origin: location.origin, + lessons, + mode, + weeksAhead, + }, + getAccessTokenFromBackground, + options, + ); + + return result; +} + +export function formatSyncResultMessage(result: GoogleCalendarSyncResult): string { + const created = result.created ?? 0; + const updated = result.updated ?? 0; + const deleted = result.deleted ?? 0; + const parts: string[] = []; + if (created > 0) parts.push(`${created} new`); + if (updated > 0) parts.push(`${updated} updated`); + if (deleted > 0) parts.push(`${deleted} removed`); + if (parts.length === 0) return "Google Calendar is up to date."; + return `Google Calendar updated (${parts.join(", ")}).`; +} diff --git a/src/seqta/utils/googleCalendar/syncSettings.ts b/src/seqta/utils/googleCalendar/syncSettings.ts new file mode 100644 index 00000000..3f1ab00e --- /dev/null +++ b/src/seqta/utils/googleCalendar/syncSettings.ts @@ -0,0 +1,10 @@ +export { + CALENDAR_WEEKLY_ALARM as GOOGLE_CALENDAR_WEEKLY_ALARM, + WEEKLY_SYNC_INTERVAL_MS, + clampSyncWeeks, + getAutoSyncWeekly, + getSyncWeeksAhead, + markWeeklySyncComplete, + markWeeklySyncPending, + shouldRunWeeklySync, +} from "@/seqta/utils/calendarSync/settings"; diff --git a/src/seqta/utils/googleCalendar/types.ts b/src/seqta/utils/googleCalendar/types.ts new file mode 100644 index 00000000..c7c14465 --- /dev/null +++ b/src/seqta/utils/googleCalendar/types.ts @@ -0,0 +1,75 @@ +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[]; + mode?: "full" | "incremental"; + weeksAhead?: number; +} + +export type GoogleCalendarSyncPhase = "preparing" | "deleting" | "upserting" | "done"; + +export interface GoogleCalendarSyncProgress { + phase: GoogleCalendarSyncPhase; + current: number; + total: number; + message: string; +} + +export interface GoogleCalendarSyncOptions { + onProgress?: (progress: GoogleCalendarSyncProgress) => void; +} + +export interface GoogleCalendarSyncResult { + success: boolean; + connected?: boolean; + configured?: boolean; + created?: number; + updated?: number; + deleted?: number; + skipped?: number; + failed?: number; + lastSyncAt?: number; + error?: string; +} + +export interface GoogleCalendarStatus { + configured: boolean; + connected: boolean; + lastSyncAt?: number; + lastWeeklySyncAt?: number; + lastSyncOrigin?: string; + syncWeeksAhead?: number; + autoSyncWeekly?: boolean; +} + +export interface GoogleCalendarDeleteResult { + success: boolean; + configured?: boolean; + connected?: boolean; + deleted?: number; + failed?: number; + error?: string; +} diff --git a/src/seqta/utils/googleCalendar/upsertEvent.ts b/src/seqta/utils/googleCalendar/upsertEvent.ts new file mode 100644 index 00000000..81a0c7bb --- /dev/null +++ b/src/seqta/utils/googleCalendar/upsertEvent.ts @@ -0,0 +1,63 @@ +import { GOOGLE_CALENDAR_API } from "@/config/googleCalendar"; + +export async function upsertGoogleCalendarEvent( + accessToken: string, + calendarId: string, + existingEventId: string | undefined, + body: Record, + refreshAccessToken?: () => Promise, +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }; + + if (existingEventId) { + const res = await fetch( + `${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(existingEventId)}`, + { method: "PATCH", headers, body: JSON.stringify(body) }, + ); + if (res.status === 401 && refreshAccessToken) { + const nextToken = await refreshAccessToken(); + return upsertGoogleCalendarEvent(nextToken, calendarId, existingEventId, body); + } + if (res.ok) return existingEventId; + if (res.status !== 404) { + const err = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; + throw new Error(err?.error?.message ?? `Google Calendar update failed (${res.status})`); + } + } + + const res = await fetch( + `${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events`, + { method: "POST", headers, body: JSON.stringify(body) }, + ); + if (res.status === 401 && refreshAccessToken) { + const nextToken = await refreshAccessToken(); + return upsertGoogleCalendarEvent(nextToken, calendarId, undefined, body); + } + const json = (await res.json().catch(() => ({}))) as { id?: string; error?: { message?: string } }; + if (!res.ok || !json.id) { + throw new Error(json?.error?.message ?? `Google Calendar create failed (${res.status})`); + } + return json.id; +} + +export async function deleteGoogleCalendarEvent( + accessToken: string, + calendarId: string, + eventId: string, + refreshAccessToken?: () => Promise, +): Promise { + const res = await fetch( + `${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, + { method: "DELETE", headers: { Authorization: `Bearer ${accessToken}` } }, + ); + if (res.status === 401 && refreshAccessToken) { + const nextToken = await refreshAccessToken(); + return deleteGoogleCalendarEvent(nextToken, calendarId, eventId); + } + if (res.ok || res.status === 404 || res.status === 410) return; + const err = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; + throw new Error(err?.error?.message ?? `Google Calendar delete failed (${res.status})`); +} diff --git a/src/seqta/utils/outlookCalendar/accountsToken.ts b/src/seqta/utils/outlookCalendar/accountsToken.ts new file mode 100644 index 00000000..7a57e916 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/accountsToken.ts @@ -0,0 +1,77 @@ +import { + OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT, + OUTLOOK_CALENDAR_REFRESH_URL, + OUTLOOK_CALENDAR_TOKEN_URL, +} from "@/config/outlookCalendar"; + +type OutlookTokenPayload = { + access_token: string; + refresh_token?: string; + expires_in?: number; +}; + +async function parseAccountsJson(res: Response): Promise> { + const text = await res.text(); + try { + return text ? (JSON.parse(text) as Record) : {}; + } catch { + return {}; + } +} + +function extractTokens(json: Record): OutlookTokenPayload { + const access_token = json.access_token; + if (typeof access_token !== "string" || !access_token) { + throw new Error("Token response missing access_token"); + } + return { + access_token, + refresh_token: typeof json.refresh_token === "string" ? json.refresh_token : undefined, + expires_in: typeof json.expires_in === "number" ? json.expires_in : undefined, + }; +} + +function formatAccountsTokenError(res: Response, json: Record): string { + if (res.status === 404 || res.status === 501) { + return OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT; + } + const err = typeof json.error === "string" ? json.error : ""; + const desc = typeof json.error_description === "string" ? json.error_description : ""; + return desc || err || `Accounts token API failed (${res.status})`; +} + +export async function exchangeOutlookCodeViaAccounts( + code: string, + redirectUri: string, + codeVerifier: string, +): Promise { + const res = await fetch(OUTLOOK_CALENDAR_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + const json = await parseAccountsJson(res); + if (!res.ok) { + throw new Error(formatAccountsTokenError(res, json)); + } + return extractTokens(json); +} + +export async function refreshOutlookTokenViaAccounts( + refreshToken: string, +): Promise { + const res = await fetch(OUTLOOK_CALENDAR_REFRESH_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + const json = await parseAccountsJson(res); + if (!res.ok) { + throw new Error(formatAccountsTokenError(res, json)); + } + return extractTokens(json); +} diff --git a/src/seqta/utils/outlookCalendar/eventMapper.ts b/src/seqta/utils/outlookCalendar/eventMapper.ts new file mode 100644 index 00000000..9fd490db --- /dev/null +++ b/src/seqta/utils/outlookCalendar/eventMapper.ts @@ -0,0 +1,36 @@ +import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar"; +import type { + GoogleCalendarEventInput, + SeqtaTimetableLesson, +} from "@/seqta/utils/googleCalendar/types"; +import { + lessonToGoogleEvent, + mapLessonsToGoogleEvents, +} from "@/seqta/utils/googleCalendar/eventMapper"; + +export { mapLessonsToGoogleEvents, lessonToGoogleEvent, seqtaLessonKey } from "@/seqta/utils/googleCalendar/eventMapper"; + +export function outlookGraphEventBody(event: GoogleCalendarEventInput): Record { + const body: Record = { + subject: event.summary, + body: { + contentType: "text", + content: event.description ?? "Synced by BetterSEQTA+", + }, + start: { dateTime: event.startDateTime, timeZone: event.timeZone }, + end: { dateTime: event.endDateTime, timeZone: event.timeZone }, + categories: [BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY], + }; + if (event.location) { + body.location = { displayName: event.location }; + } + return body; +} + +export function mapLessonsToOutlookEvents( + origin: string, + lessons: SeqtaTimetableLesson[], + timeZone: string, +): GoogleCalendarEventInput[] { + return mapLessonsToGoogleEvents(origin, lessons, timeZone); +} diff --git a/src/seqta/utils/outlookCalendar/storage.ts b/src/seqta/utils/outlookCalendar/storage.ts new file mode 100644 index 00000000..1b5f5f0a --- /dev/null +++ b/src/seqta/utils/outlookCalendar/storage.ts @@ -0,0 +1,38 @@ +import browser from "webextension-polyfill"; +import type { GoogleCalendarEventMapEntry } from "@/seqta/utils/googleCalendar/eventMapEntry"; + +export const BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY = "bsplus_outlook_calendar"; + +export interface OutlookCalendarStoredState { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + connectedAt?: number; + lastSyncAt?: number; + lastSyncOrigin?: string; + eventMap?: Record; +} + +export async function readOutlookCalendarState(): Promise { + const got = await browser.storage.local.get(BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY); + const raw = got[BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY]; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return raw as OutlookCalendarStoredState; +} + +export async function writeOutlookCalendarState( + patch: Partial, +): Promise { + const current = await readOutlookCalendarState(); + const next: OutlookCalendarStoredState = { ...current, ...patch }; + await browser.storage.local.set({ [BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY]: next }); + return next; +} + +export async function clearOutlookCalendarState(): Promise { + await browser.storage.local.remove(BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY); +} + +export function outlookEventMapKey(origin: string, seqtaKey: string): string { + return `${origin}::${seqtaKey}`; +} diff --git a/src/seqta/utils/outlookCalendar/syncEngine.ts b/src/seqta/utils/outlookCalendar/syncEngine.ts new file mode 100644 index 00000000..345ea1dc --- /dev/null +++ b/src/seqta/utils/outlookCalendar/syncEngine.ts @@ -0,0 +1,249 @@ +import { verboseLog } from "@/utils/verboseLog"; +import { isOutlookCalendarConfigured } from "@/config/outlookCalendar"; +import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; +import { + buildLessonSyncResult, + emptyLessonsSyncResult, + entriesToPrune, + EVENT_MAP_PERSIST_EVERY, + notConfiguredSyncResult, + notConnectedSyncResult, + originEventMapEntries, + persistFinalSyncState, + reportSyncProgress, + upsertLessonEvents, +} from "@/seqta/utils/calendarSync/lessonSyncShared"; +import type { + GoogleCalendarDeleteResult, + GoogleCalendarSyncOptions, + GoogleCalendarSyncRequest, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; +import { + mapLessonsToOutlookEvents, + outlookGraphEventBody, +} from "@/seqta/utils/outlookCalendar/eventMapper"; +import { + outlookEventMapKey, + readOutlookCalendarState, + writeOutlookCalendarState, +} from "@/seqta/utils/outlookCalendar/storage"; +import { + deleteOutlookCalendarEvent, + upsertOutlookCalendarEvent, +} from "@/seqta/utils/outlookCalendar/upsertEvent"; + +type DeleteTrackedEventsResult = { + deleted: number; + failed: number; +}; + +async function deleteTrackedEventsFromOutlook( + entries: Array<[string, string]>, + eventMap: Record, + getAccessToken: () => Promise, + persistProgress = false, + onProgress?: GoogleCalendarSyncOptions["onProgress"], + progressOffset = 0, + progressTotal = 0, +): Promise { + if (entries.length === 0) return { deleted: 0, failed: 0 }; + + let accessToken = await getAccessToken(); + let deleted = 0; + let failed = 0; + + for (const [mapKey, eventId] of entries) { + try { + await deleteOutlookCalendarEvent(accessToken, eventId, async () => { + accessToken = await getAccessToken(); + return accessToken; + }); + delete eventMap[mapKey]; + deleted += 1; + + reportSyncProgress(onProgress, { + phase: "deleting", + current: progressOffset + deleted + failed, + total: progressTotal, + message: `Removing old events (${deleted + failed}/${entries.length})…`, + }); + + if (persistProgress && (deleted + failed) % EVENT_MAP_PERSIST_EVERY === 0) { + await writeOutlookCalendarState({ eventMap }); + } + } catch (err) { + verboseLog("[BetterSEQTA+] Outlook Calendar event delete failed:", err); + failed += 1; + reportSyncProgress(onProgress, { + phase: "deleting", + current: progressOffset + deleted + failed, + total: progressTotal, + message: `Removing old events (${deleted + failed}/${entries.length})…`, + }); + } + } + + return { deleted, failed }; +} + +export async function syncLessonsToOutlookCalendar( + request: GoogleCalendarSyncRequest, + getAccessToken: () => Promise, + options: GoogleCalendarSyncOptions = {}, +): Promise { + if (!isOutlookCalendarConfigured()) { + return notConfiguredSyncResult( + "Outlook Calendar is not configured in this extension build.", + ); + } + + const state = await readOutlookCalendarState(); + if (!state.refreshToken && !state.accessToken) { + return notConnectedSyncResult("Connect Outlook Calendar first."); + } + + const mode = request.mode ?? "full"; + const weeksAhead = request.weeksAhead ?? (await getSyncWeeksAhead()); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const events = mapLessonsToOutlookEvents(request.origin, request.lessons, timeZone); + + if (events.length === 0 && mode === "full") { + return emptyLessonsSyncResult(); + } + + reportSyncProgress(options.onProgress, { + phase: "preparing", + current: 0, + total: Math.max(events.length, 1), + message: mode === "incremental" ? "Preparing weekly sync…" : "Preparing sync…", + }); + + const eventMap = { ...(state.eventMap ?? {}) }; + const currentMapKeys = new Set( + events.map((event) => outlookEventMapKey(request.origin, event.seqtaKey)), + ); + const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys); + const totalSteps = staleEntries.length + events.length; + const lastSyncAt = Date.now(); + + const staleResult = await deleteTrackedEventsFromOutlook( + staleEntries, + eventMap, + getAccessToken, + false, + options.onProgress, + 0, + totalSteps, + ); + + const upsertResult = await upsertLessonEvents({ + events, + eventMap, + origin: request.origin, + staleEntryCount: staleEntries.length, + totalSteps, + lastSyncAt, + initialFailed: staleResult.failed, + getAccessToken, + mapKey: outlookEventMapKey, + upsert: (accessToken, existingId, event, refreshAccessToken) => + upsertOutlookCalendarEvent( + accessToken, + existingId, + outlookGraphEventBody(event), + refreshAccessToken, + ), + writeState: writeOutlookCalendarState, + onProgress: options.onProgress, + logLabel: "Outlook Calendar", + }); + + await persistFinalSyncState( + writeOutlookCalendarState, + eventMap, + lastSyncAt, + request.origin, + staleResult.deleted, + staleEntries.length, + events.length, + ); + + reportSyncProgress(options.onProgress, { + phase: "done", + current: totalSteps, + total: totalSteps, + message: "Sync complete", + }); + + return buildLessonSyncResult( + upsertResult.created, + upsertResult.updated, + staleResult.deleted, + upsertResult.failed, + lastSyncAt, + ); +} + +export async function deleteSyncedEventsFromOutlookCalendar( + origin: string, + getAccessToken: () => Promise, + options: GoogleCalendarSyncOptions = {}, +): Promise { + if (!isOutlookCalendarConfigured()) { + return { + success: false, + configured: false, + error: "Outlook Calendar is not configured in this extension build.", + }; + } + + const state = await readOutlookCalendarState(); + if (!state.refreshToken && !state.accessToken) { + return { success: false, configured: true, connected: false, error: "Connect Outlook Calendar first." }; + } + + const entries = originEventMapEntries(state.eventMap ?? {}, origin); + if (entries.length === 0) { + return { success: true, configured: true, connected: true, deleted: 0, failed: 0 }; + } + + reportSyncProgress(options.onProgress, { + phase: "preparing", + current: 0, + total: entries.length, + message: "Preparing removal…", + }); + + const eventMap = { ...(state.eventMap ?? {}) }; + const { deleted, failed } = await deleteTrackedEventsFromOutlook( + entries, + eventMap, + getAccessToken, + true, + options.onProgress, + 0, + entries.length, + ); + + await writeOutlookCalendarState({ eventMap }); + + reportSyncProgress(options.onProgress, { + phase: "done", + current: entries.length, + total: entries.length, + message: "Removal complete", + }); + + return { + success: failed === 0, + configured: true, + connected: true, + deleted, + failed, + error: + failed > 0 + ? `Removed ${deleted} event${deleted === 1 ? "" : "s"} with ${failed} error${failed === 1 ? "" : "s"}.` + : undefined, + }; +} diff --git a/src/seqta/utils/outlookCalendar/syncRunner.ts b/src/seqta/utils/outlookCalendar/syncRunner.ts new file mode 100644 index 00000000..4758f642 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/syncRunner.ts @@ -0,0 +1,76 @@ +import browser from "webextension-polyfill"; +import { + fetchTimetableForSync, + fetchTimetableLessons, + trailingWeekRange, +} from "@/seqta/utils/googleCalendar/fetchTimetable"; +import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; +import { syncLessonsToOutlookCalendar } from "@/seqta/utils/outlookCalendar/syncEngine"; +import type { + GoogleCalendarSyncOptions, + GoogleCalendarSyncProgress, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; + +export type OutlookCalendarRunMode = "full" | "incremental"; + +export interface RunOutlookCalendarSyncParams { + mode?: OutlookCalendarRunMode; + silent?: boolean; + onProgress?: (progress: GoogleCalendarSyncProgress) => void; +} + +async function getAccessTokenFromBackground(): Promise { + const res = (await browser.runtime.sendMessage({ + type: "outlookCalendarGetAccessToken", + })) as { success?: boolean; accessToken?: string; error?: string }; + if (!res?.success || !res.accessToken) { + throw new Error(res?.error ?? "Could not get Outlook Calendar access token."); + } + return res.accessToken; +} + +export async function runOutlookCalendarSync( + params: RunOutlookCalendarSyncParams = {}, +): Promise { + const mode = params.mode ?? "full"; + const weeksAhead = await getSyncWeeksAhead(); + + params.onProgress?.({ + phase: "preparing", + current: 0, + total: 1, + message: mode === "incremental" ? "Fetching new week…" : "Fetching timetable…", + }); + + const lessons = + mode === "incremental" + ? await fetchTimetableLessons(trailingWeekRange(weeksAhead)) + : await fetchTimetableForSync(weeksAhead); + + const options: GoogleCalendarSyncOptions = { onProgress: params.onProgress }; + const result = await syncLessonsToOutlookCalendar( + { + origin: location.origin, + lessons, + mode, + weeksAhead, + }, + getAccessTokenFromBackground, + options, + ); + + return result; +} + +export function formatOutlookSyncResultMessage(result: GoogleCalendarSyncResult): string { + const created = result.created ?? 0; + const updated = result.updated ?? 0; + const deleted = result.deleted ?? 0; + const parts: string[] = []; + if (created > 0) parts.push(`${created} new`); + if (updated > 0) parts.push(`${updated} updated`); + if (deleted > 0) parts.push(`${deleted} removed`); + if (parts.length === 0) return "Outlook Calendar is up to date."; + return `Outlook Calendar updated (${parts.join(", ")}).`; +} diff --git a/src/seqta/utils/outlookCalendar/types.ts b/src/seqta/utils/outlookCalendar/types.ts new file mode 100644 index 00000000..75b1a23c --- /dev/null +++ b/src/seqta/utils/outlookCalendar/types.ts @@ -0,0 +1,6 @@ +export interface OutlookCalendarStatus { + configured: boolean; + connected: boolean; + lastSyncAt?: number; + lastSyncOrigin?: string; +} diff --git a/src/seqta/utils/outlookCalendar/upsertEvent.test.ts b/src/seqta/utils/outlookCalendar/upsertEvent.test.ts new file mode 100644 index 00000000..410075e0 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/upsertEvent.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { upsertOutlookCalendarEvent, deleteOutlookCalendarEvent } from "./upsertEvent"; + +const fetchMock = jest.fn(); +global.fetch = fetchMock as unknown as typeof fetch; + +describe("upsertOutlookCalendarEvent", () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + it("creates a new event when no existing id", async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: "evt-1" }), + }); + + const id = await upsertOutlookCalendarEvent("token", undefined, { subject: "Math" }); + expect(id).toBe("evt-1"); + expect(fetchMock).toHaveBeenCalledWith( + "https://graph.microsoft.com/v1.0/me/events", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("patches when an existing id is provided", async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); + + const id = await upsertOutlookCalendarEvent("token", "evt-1", { subject: "Math" }); + expect(id).toBe("evt-1"); + expect(fetchMock).toHaveBeenCalledWith( + "https://graph.microsoft.com/v1.0/me/events/evt-1", + expect.objectContaining({ method: "PATCH" }), + ); + }); +}); + +describe("deleteOutlookCalendarEvent", () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + it("treats 404 as success", async () => { + fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) }); + await expect(deleteOutlookCalendarEvent("token", "evt-1")).resolves.toBeUndefined(); + }); +}); diff --git a/src/seqta/utils/outlookCalendar/upsertEvent.ts b/src/seqta/utils/outlookCalendar/upsertEvent.ts new file mode 100644 index 00000000..1c5fc182 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/upsertEvent.ts @@ -0,0 +1,68 @@ +import { OUTLOOK_GRAPH_API } from "@/config/outlookCalendar"; + +export async function upsertOutlookCalendarEvent( + accessToken: string, + existingEventId: string | undefined, + body: Record, + refreshAccessToken?: () => Promise, +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }; + + if (existingEventId) { + const res = await fetch(`${OUTLOOK_GRAPH_API}/me/events/${encodeURIComponent(existingEventId)}`, { + method: "PATCH", + headers, + body: JSON.stringify(body), + }); + if (res.status === 401 && refreshAccessToken) { + const nextToken = await refreshAccessToken(); + return upsertOutlookCalendarEvent(nextToken, existingEventId, body); + } + if (res.ok) return existingEventId; + if (res.status !== 404) { + const err = (await res.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + throw new Error(err?.error?.message ?? `Outlook Calendar update failed (${res.status})`); + } + } + + const res = await fetch(`${OUTLOOK_GRAPH_API}/me/events`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + if (res.status === 401 && refreshAccessToken) { + const nextToken = await refreshAccessToken(); + return upsertOutlookCalendarEvent(nextToken, undefined, body); + } + const json = (await res.json().catch(() => ({}))) as { + id?: string; + error?: { message?: string }; + }; + if (!res.ok || !json.id) { + throw new Error(json?.error?.message ?? `Outlook Calendar create failed (${res.status})`); + } + return json.id; +} + +export async function deleteOutlookCalendarEvent( + accessToken: string, + eventId: string, + refreshAccessToken?: () => Promise, +): Promise { + const res = await fetch(`${OUTLOOK_GRAPH_API}/me/events/${encodeURIComponent(eventId)}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (res.status === 401 && refreshAccessToken) { + const nextToken = await refreshAccessToken(); + return deleteOutlookCalendarEvent(nextToken, eventId); + } + if (res.ok || res.status === 404 || res.status === 410) return; + const err = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; + throw new Error(err?.error?.message ?? `Outlook Calendar delete failed (${res.status})`); +} diff --git a/src/test/mocks/color.ts b/src/test/mocks/color.ts new file mode 100644 index 00000000..585602a5 --- /dev/null +++ b/src/test/mocks/color.ts @@ -0,0 +1,86 @@ +type ColorChannels = { + r: number; + g: number; + b: number; + a: number; +}; + +function clampByte(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function clampAlpha(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function toHexByte(value: number): string { + return clampByte(value).toString(16).padStart(2, "0"); +} + +function createColor(channels: ColorChannels) { + const color = { + red: () => clampByte(channels.r), + green: () => clampByte(channels.g), + blue: () => clampByte(channels.b), + alpha: () => clampAlpha(channels.a), + hex: () => + `#${toHexByte(channels.r)}${toHexByte(channels.g)}${toHexByte(channels.b)}`, + }; + + return { + ...color, + alpha: (value?: number) => { + if (value === undefined) return clampAlpha(channels.a); + return createColor({ ...channels, a: value }); + }, + }; +} + +function parseHex(input: string): ColorChannels | null { + const short = input.match(/^#([0-9a-f]{3})$/i); + if (short) { + const [r, g, b] = short[1].split(""); + return { + r: parseInt(`${r}${r}`, 16), + g: parseInt(`${g}${g}`, 16), + b: parseInt(`${b}${b}`, 16), + a: 1, + }; + } + + const long = input.match(/^#([0-9a-f]{6})$/i); + if (!long) return null; + + return { + r: parseInt(long[1].slice(0, 2), 16), + g: parseInt(long[1].slice(2, 4), 16), + b: parseInt(long[1].slice(4, 6), 16), + a: 1, + }; +} + +function parseRgb(input: string): ColorChannels | null { + const match = input.match( + /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i, + ); + if (!match) return null; + + return { + r: Number(match[1]), + g: Number(match[2]), + b: Number(match[3]), + a: match[4] !== undefined ? Number(match[4]) : 1, + }; +} + +function Color(input: string) { + const channels = parseHex(input) ?? parseRgb(input); + if (!channels) { + throw new Error(`Unable to parse color: ${input}`); + } + return createColor(channels); +} + +Color.rgb = (r: number, g: number, b: number) => createColor({ r, g, b, a: 1 }); + +export default Color; diff --git a/vite.config.ts b/vite.config.ts index a0715009..f02080ad 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"; @@ -60,7 +60,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. @@ -74,7 +80,10 @@ 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 ?? ""), + __OUTLOOK_OAUTH_CLIENT_ID__: JSON.stringify(env.OUTLOOK_OAUTH_CLIENT_ID ?? ""), }, + envDir: repoRoot, plugins: [ svelte({ emitCss: false, @@ -166,4 +175,5 @@ export default defineConfig(({ command }) => ({ }, }, }, -})); +}; +});