diff --git a/.env.example b/.env.example index ba69324b..ded48c13 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,6 @@ # See docs/GOOGLE_CALENDAR_ACCOUNTS_CALLBACK.md # GOOGLE_OAUTH_CLIENT_ID=your-id.apps.googleusercontent.com + +# Outlook / Microsoft Graph (see docs/OUTLOOK_CALENDAR_ACCOUNTS_CALLBACK.md) +# OUTLOOK_OAUTH_CLIENT_ID=your-azure-application-client-id diff --git a/src/background.ts b/src/background.ts index 0ca8b442..67af512a 100644 --- a/src/background.ts +++ b/src/background.ts @@ -14,6 +14,7 @@ import { } from "./background/cloudSettingsAutoSync"; import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl"; import { registerGoogleCalendarMessageHandlers, initGoogleCalendarBackground } from "./background/googleCalendar"; +import { registerOutlookCalendarMessageHandlers } from "./background/outlookCalendar"; /** * Session-only dev-mode override of the content API base. @@ -559,6 +560,7 @@ const MESSAGE_HANDLERS: Record = { }; registerGoogleCalendarMessageHandlers(MESSAGE_HANDLERS, isTrustedSender); +registerOutlookCalendarMessageHandlers(MESSAGE_HANDLERS, isTrustedSender); initGoogleCalendarBackground(); browser.runtime.onMessage.addListener( diff --git a/src/background/googleCalendarWeekly.ts b/src/background/calendarWeekly.ts similarity index 59% rename from src/background/googleCalendarWeekly.ts rename to src/background/calendarWeekly.ts index b1ece4b1..9a7d2090 100644 --- a/src/background/googleCalendarWeekly.ts +++ b/src/background/calendarWeekly.ts @@ -1,11 +1,12 @@ import browser from "webextension-polyfill"; import { verboseLog } from "@/utils/verboseLog"; import { + CALENDAR_WEEKLY_ALARM, getAutoSyncWeekly, - GOOGLE_CALENDAR_WEEKLY_ALARM, markWeeklySyncPending, -} from "@/seqta/utils/googleCalendar/syncSettings"; +} from "@/seqta/utils/calendarSync/settings"; import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; +import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage"; const WEEKLY_PERIOD_MINUTES = 7 * 24 * 60; @@ -14,29 +15,37 @@ function isSeqtaTab(tab: browser.Tabs.Tab): boolean { return title.includes("SEQTA Learn") || title.includes("SEQTA Engage"); } -async function isCalendarConnected(): Promise { - const state = await readGoogleCalendarState(); - return !!(state.refreshToken || state.accessToken); +async function isAnyCalendarConnected(): Promise { + const [google, outlook] = await Promise.all([ + readGoogleCalendarState(), + readOutlookCalendarState(), + ]); + return !!( + google.refreshToken || + google.accessToken || + outlook.refreshToken || + outlook.accessToken + ); } export async function ensureWeeklySyncAlarm(): Promise { - const connected = await isCalendarConnected(); + const connected = await isAnyCalendarConnected(); const enabled = await getAutoSyncWeekly(); if (!connected || !enabled) { - await browser.alarms.clear(GOOGLE_CALENDAR_WEEKLY_ALARM); + await browser.alarms.clear(CALENDAR_WEEKLY_ALARM); return; } - const existing = await browser.alarms.get(GOOGLE_CALENDAR_WEEKLY_ALARM); + const existing = await browser.alarms.get(CALENDAR_WEEKLY_ALARM); if (!existing) { - await browser.alarms.create(GOOGLE_CALENDAR_WEEKLY_ALARM, { + await browser.alarms.create(CALENDAR_WEEKLY_ALARM, { periodInMinutes: WEEKLY_PERIOD_MINUTES, }); } } export async function clearWeeklySyncAlarm(): Promise { - await browser.alarms.clear(GOOGLE_CALENDAR_WEEKLY_ALARM); + await browser.alarms.clear(CALENDAR_WEEKLY_ALARM); } export async function triggerWeeklySyncOnSeqtaTabs(): Promise { @@ -48,7 +57,7 @@ export async function triggerWeeklySyncOnSeqtaTabs(): Promise { for (const tab of seqtaTabs) { if (tab.id == null) continue; try { - await browser.tabs.sendMessage(tab.id, { type: "googleCalendarRunWeeklySync" }); + await browser.tabs.sendMessage(tab.id, { type: "calendarRunWeeklySync" }); delivered = true; } catch (err) { verboseLog("[BetterSEQTA+] Weekly calendar sync message failed for tab:", tab.id, err); @@ -58,7 +67,7 @@ export async function triggerWeeklySyncOnSeqtaTabs(): Promise { } export async function handleWeeklySyncAlarm(): Promise { - if (!(await isCalendarConnected()) || !(await getAutoSyncWeekly())) return; + if (!(await isAnyCalendarConnected()) || !(await getAutoSyncWeekly())) return; const delivered = await triggerWeeklySyncOnSeqtaTabs(); if (!delivered) { @@ -68,7 +77,12 @@ export async function handleWeeklySyncAlarm(): Promise { export function registerWeeklySyncAlarmListener(): void { browser.alarms.onAlarm.addListener((alarm) => { - if (alarm.name !== GOOGLE_CALENDAR_WEEKLY_ALARM) return; + if (alarm.name !== CALENDAR_WEEKLY_ALARM) return; void handleWeeklySyncAlarm(); }); } + +export function initCalendarBackground(): void { + registerWeeklySyncAlarmListener(); + void ensureWeeklySyncAlarm(); +} diff --git a/src/background/googleCalendar.ts b/src/background/googleCalendar.ts index 86ccb2f0..9042e855 100644 --- a/src/background/googleCalendar.ts +++ b/src/background/googleCalendar.ts @@ -20,16 +20,16 @@ import { clampSyncWeeks, getAutoSyncWeekly, getSyncWeeksAhead, -} from "@/seqta/utils/googleCalendar/syncSettings"; +} from "@/seqta/utils/calendarSync/settings"; +import { + readSharedCalendarSyncSettings, + writeSharedCalendarSyncSettings, +} from "@/seqta/utils/calendarSync/sharedSettings"; import type { GoogleCalendarStatus, GoogleCalendarSyncResult, } from "@/seqta/utils/googleCalendar/types"; -import { - clearWeeklySyncAlarm, - ensureWeeklySyncAlarm, - registerWeeklySyncAlarmListener, -} from "./googleCalendarWeekly"; +import { ensureWeeklySyncAlarm, initCalendarBackground } from "./calendarWeekly"; function base64UrlEncode(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); @@ -221,13 +221,14 @@ async function connectGoogleCalendar(): Promise { async function getGoogleCalendarStatus(): Promise { const state = await readGoogleCalendarState(); + const shared = await readSharedCalendarSyncSettings(); const syncWeeksAhead = await getSyncWeeksAhead(); const autoSyncWeekly = await getAutoSyncWeekly(); return { configured: isGoogleCalendarConfigured(), connected: !!(state.refreshToken || state.accessToken), lastSyncAt: state.lastSyncAt, - lastWeeklySyncAt: state.lastWeeklySyncAt, + lastWeeklySyncAt: shared.lastWeeklySyncAt, lastSyncOrigin: state.lastSyncOrigin, syncWeeksAhead, autoSyncWeekly, @@ -239,8 +240,8 @@ export async function handleGoogleCalendarConnect(): Promise { - await clearWeeklySyncAlarm(); await clearGoogleCalendarState(); + await ensureWeeklySyncAlarm(); return { success: true }; } @@ -345,7 +346,7 @@ export function registerGoogleCalendarMessageHandlers( patch.autoSyncWeekly = !!body.autoSyncWeekly; } if (Object.keys(patch).length > 0) { - await writeGoogleCalendarState(patch); + await writeSharedCalendarSyncSettings(patch); } await ensureWeeklySyncAlarm(); sendResponse({ success: true, ...(await getGoogleCalendarStatus()) }); @@ -359,7 +360,4 @@ export function registerGoogleCalendarMessageHandlers( }; } -export function initGoogleCalendarBackground(): void { - registerWeeklySyncAlarmListener(); - void ensureWeeklySyncAlarm(); -} +export { initCalendarBackground as initGoogleCalendarBackground } from "./calendarWeekly"; diff --git a/src/background/outlookCalendar.ts b/src/background/outlookCalendar.ts new file mode 100644 index 00000000..bbbd96c8 --- /dev/null +++ b/src/background/outlookCalendar.ts @@ -0,0 +1,302 @@ +import browser from "webextension-polyfill"; +import { + OUTLOOK_AUTH_URL, + OUTLOOK_CALENDAR_OAUTH_CALLBACK, + OUTLOOK_CALENDAR_SCOPE, + OUTLOOK_OAUTH_CLIENT_ID, + isOutlookCalendarConfigured, + outlookOAuthRedirectUriHint, +} from "@/config/outlookCalendar"; +import { + exchangeOutlookCodeViaAccounts, + refreshOutlookTokenViaAccounts, +} from "@/seqta/utils/outlookCalendar/accountsToken"; +import { + clearOutlookCalendarState, + readOutlookCalendarState, + writeOutlookCalendarState, +} from "@/seqta/utils/outlookCalendar/storage"; +import type { OutlookCalendarStatus } from "@/seqta/utils/outlookCalendar/types"; +import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; +import { ensureWeeklySyncAlarm } from "./calendarWeekly"; + +function base64UrlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +async function sha256(input: string): Promise { + const data = new TextEncoder().encode(input); + return crypto.subtle.digest("SHA-256", data); +} + +function randomVerifier(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes.buffer); +} + +async function pkceChallenge(verifier: string): Promise { + return base64UrlEncode(await sha256(verifier)); +} + +function parseRedirectCode(responseUrl: string): string { + const url = new URL(responseUrl); + const err = url.searchParams.get("error"); + if (err) throw new Error(url.searchParams.get("error_description") ?? err); + const code = url.searchParams.get("code"); + if (!code) throw new Error("Microsoft sign-in did not return an authorization code."); + return code; +} + +const OAUTH_CALLBACK_PREFIX = OUTLOOK_CALENDAR_OAUTH_CALLBACK; +const OAUTH_TIMEOUT_MS = 10 * 60 * 1000; + +function isOAuthCallbackUrl(url: string): boolean { + if (!url.startsWith(OAUTH_CALLBACK_PREFIX)) return false; + const parsed = new URL(url); + return parsed.searchParams.has("code") || parsed.searchParams.has("error"); +} + +function waitForOAuthCallback(authTabId: number): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + cleanup(); + reject(new Error("Microsoft sign-in timed out. Close the tab and try again.")); + }, OAUTH_TIMEOUT_MS); + + const cleanup = () => { + clearTimeout(timeoutId); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.tabs.onRemoved.removeListener(onRemoved); + }; + + const finishFromUrl = (url: string, tabId: number) => { + if (!isOAuthCallbackUrl(url)) return false; + cleanup(); + void browser.tabs.remove(tabId).catch(() => {}); + resolve(url); + return true; + }; + + const onRemoved = (tabId: number) => { + if (tabId !== authTabId) return; + cleanup(); + reject(new Error("Microsoft sign-in was cancelled.")); + }; + + const onUpdated = ( + tabId: number, + changeInfo: browser.Tabs.OnUpdatedChangeInfoType, + tab: browser.Tabs.Tab, + ) => { + if (tabId !== authTabId) return; + const url = + changeInfo.url ?? (changeInfo.status === "complete" ? tab.url : undefined); + if (url) finishFromUrl(url, tabId); + }; + + browser.tabs.onUpdated.addListener(onUpdated); + browser.tabs.onRemoved.addListener(onRemoved); + + void browser.tabs + .get(authTabId) + .then((tab) => { + if (tab.url) finishFromUrl(tab.url, authTabId); + }) + .catch(() => {}); + }); +} + +async function openOAuthTab(authUrl: string): Promise { + const tab = await browser.tabs.create({ url: authUrl, active: true }); + if (tab.id === undefined) { + throw new Error("Could not open Microsoft sign-in tab."); + } + return waitForOAuthCallback(tab.id); +} + +async function getValidAccessToken(): Promise { + const state = await readOutlookCalendarState(); + const now = Date.now(); + if (state.accessToken && state.expiresAt && state.expiresAt > now + 60_000) { + return state.accessToken; + } + if (!state.refreshToken) { + throw new Error("Not connected to Outlook Calendar."); + } + const refreshed = await refreshOutlookTokenViaAccounts(state.refreshToken); + const expiresAt = refreshed.expires_in + ? Date.now() + refreshed.expires_in * 1000 + : Date.now() + 3_600_000; + await writeOutlookCalendarState({ + accessToken: refreshed.access_token, + refreshToken: refreshed.refresh_token ?? state.refreshToken, + expiresAt, + }); + return refreshed.access_token; +} + +async function connectOutlookCalendar(): Promise { + if (!isOutlookCalendarConfigured()) { + return { + success: false, + configured: false, + error: "Outlook Calendar is not configured in this extension build.", + }; + } + + const redirectUri = OUTLOOK_CALENDAR_OAUTH_CALLBACK; + const verifier = randomVerifier(); + const challenge = await pkceChallenge(verifier); + const authUrl = new URL(OUTLOOK_AUTH_URL); + authUrl.searchParams.set("client_id", OUTLOOK_OAUTH_CLIENT_ID); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("scope", OUTLOOK_CALENDAR_SCOPE); + authUrl.searchParams.set("response_mode", "query"); + authUrl.searchParams.set("prompt", "consent"); + authUrl.searchParams.set("code_challenge", challenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + + let responseUrl: string; + try { + responseUrl = await openOAuthTab(authUrl.toString()); + } catch (err) { + const message = err instanceof Error ? err.message : "Microsoft sign-in failed"; + const mismatch = + /redirect_uri|invalid_request|AADSTS50011/i.test(message) || + /redirect_uri|invalid_request|AADSTS50011/i.test(String(err)); + return { + success: false, + configured: true, + error: mismatch + ? `Microsoft redirect URI mismatch. ${outlookOAuthRedirectUriHint()}` + : message.includes("cancel") + ? "Microsoft sign-in was cancelled." + : message, + }; + } + + try { + const code = parseRedirectCode(responseUrl); + const tokens = await exchangeOutlookCodeViaAccounts(code, redirectUri, verifier); + const expiresAt = tokens.expires_in + ? Date.now() + tokens.expires_in * 1000 + : Date.now() + 3_600_000; + + const existing = await readOutlookCalendarState(); + await writeOutlookCalendarState({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? existing.refreshToken, + expiresAt, + connectedAt: Date.now(), + }); + + await ensureWeeklySyncAlarm(); + + return { success: true, configured: true, connected: true }; + } catch (err) { + return { + success: false, + configured: true, + error: err instanceof Error ? err.message : "Microsoft sign-in failed", + }; + } +} + +async function getOutlookCalendarStatus(): Promise { + const state = await readOutlookCalendarState(); + return { + configured: isOutlookCalendarConfigured(), + connected: !!(state.refreshToken || state.accessToken), + lastSyncAt: state.lastSyncAt, + lastSyncOrigin: state.lastSyncOrigin, + }; +} + +export async function handleOutlookCalendarConnect(): Promise { + return connectOutlookCalendar(); +} + +export async function handleOutlookCalendarDisconnect(): Promise<{ success: boolean }> { + await clearOutlookCalendarState(); + await ensureWeeklySyncAlarm(); + return { success: true }; +} + +export async function handleOutlookCalendarStatus(): Promise { + return getOutlookCalendarStatus(); +} + +export function registerOutlookCalendarMessageHandlers( + handlers: Record< + string, + ( + request: unknown, + sendResponse: (response?: unknown) => void, + sender?: browser.Runtime.MessageSender, + ) => boolean | void + >, + isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean, +): void { + const rejectUntrusted = ( + sendResponse: (response?: unknown) => void, + sender?: browser.Runtime.MessageSender, + ): boolean => { + if (isTrustedSender(sender)) return false; + sendResponse({ success: false, error: "Unauthorized sender" }); + return true; + }; + + handlers.outlookCalendarConnect = (_req, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void handleOutlookCalendarConnect() + .then(sendResponse) + .catch((err) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Microsoft sign-in failed", + }); + }); + return true; + }; + + handlers.outlookCalendarDisconnect = (_req, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void handleOutlookCalendarDisconnect() + .then(sendResponse) + .catch((err) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Disconnect failed", + }); + }); + return true; + }; + + handlers.outlookCalendarStatus = (_req, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void handleOutlookCalendarStatus() + .then(sendResponse) + .catch(() => { + sendResponse({ configured: isOutlookCalendarConfigured(), connected: false }); + }); + return true; + }; + + handlers.outlookCalendarGetAccessToken = (_req, sendResponse, sender) => { + if (rejectUntrusted(sendResponse, sender)) return false; + void getValidAccessToken() + .then((accessToken) => sendResponse({ success: true, accessToken })) + .catch((err) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Token refresh failed", + }); + }); + return true; + }; +} diff --git a/src/config/outlookCalendar.ts b/src/config/outlookCalendar.ts new file mode 100644 index 00000000..bac80183 --- /dev/null +++ b/src/config/outlookCalendar.ts @@ -0,0 +1,43 @@ +/** + * Outlook Calendar OAuth — public client config (extension). + * Client secret and token exchange live on accounts.betterseqta.org. + * See docs/OUTLOOK_CALENDAR_ACCOUNTS_CALLBACK.md + */ + +import { ACCOUNTS_BASE } from "@/config/googleCalendar"; + +const HARDCODED_OUTLOOK_OAUTH_CLIENT_ID = + "0b55168c-916c-4323-8f67-b3dd30af3c9e"; + +const envClientId = + typeof __OUTLOOK_OAUTH_CLIENT_ID__ !== "undefined" ? __OUTLOOK_OAUTH_CLIENT_ID__ : ""; + +export const OUTLOOK_OAUTH_CLIENT_ID: string = + envClientId.trim() || HARDCODED_OUTLOOK_OAUTH_CLIENT_ID.trim(); + +/** Must match Azure app registration + accounts callback route exactly. */ +export const OUTLOOK_CALENDAR_OAUTH_CALLBACK = `${ACCOUNTS_BASE}/auth/microsoft/calendar/callback`; + +export const OUTLOOK_CALENDAR_TOKEN_URL = `${ACCOUNTS_BASE}/api/bsplus/microsoft/calendar/token`; +export const OUTLOOK_CALENDAR_REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/microsoft/calendar/refresh`; + +/** Delegated Graph scopes for create/update/delete calendar events. */ +export const OUTLOOK_CALENDAR_SCOPE = "offline_access Calendars.ReadWrite User.Read"; + +export const OUTLOOK_AUTH_URL = + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; + +export const OUTLOOK_GRAPH_API = "https://graph.microsoft.com/v1.0"; + +export const BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY = "BetterSEQTA+"; + +export function isOutlookCalendarConfigured(): boolean { + return OUTLOOK_OAUTH_CLIENT_ID.length > 0; +} + +export const OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT = + "Outlook Calendar connect requires accounts.betterseqta.org — see docs/OUTLOOK_CALENDAR_ACCOUNTS_CALLBACK.md"; + +export function outlookOAuthRedirectUriHint(): string { + return `Authorized redirect URI in Azure must be: ${OUTLOOK_CALENDAR_OAUTH_CALLBACK}`; +} diff --git a/src/env.d.ts b/src/env.d.ts index 6fda995d..9e093d36 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -3,3 +3,4 @@ declare const __GH_RELEASE_REPO__: string; declare const __UPDATE_CHANNEL__: "stable" | "nightly"; declare const __BUILD_LABEL__: string; declare const __GOOGLE_OAUTH_CLIENT_ID__: string; +declare const __OUTLOOK_OAUTH_CLIENT_ID__: string; diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index 812f5de8..439930a8 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -22,13 +22,15 @@ "https://accounts.betterseqta.org/", "https://www.googleapis.com/", "https://oauth2.googleapis.com/", + "https://graph.microsoft.com/", + "https://login.microsoftonline.com/", "*://*/*" ], "background": { "service_worker": "background.ts" }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org https://www.googleapis.com https://oauth2.googleapis.com" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org https://www.googleapis.com https://oauth2.googleapis.com https://graph.microsoft.com https://login.microsoftonline.com" }, "content_scripts": [ { diff --git a/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte b/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte index 5accdd94..585fad24 100644 --- a/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte +++ b/src/plugins/built-in/timetable/CalendarDeleteEventsModal.svelte @@ -4,11 +4,13 @@ let { open = false, busy = false, + providerLabel = "Google", onConfirm, onCancel, } = $props<{ open?: boolean; busy?: boolean; + providerLabel?: string; onConfirm: () => void | Promise; onCancel: () => void; }>(); @@ -37,7 +39,7 @@ Remove synced events?

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

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

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

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

diff --git a/src/plugins/built-in/timetable/CalendarSyncControl.svelte b/src/plugins/built-in/timetable/CalendarSyncControl.svelte index 7ea0b243..6610929e 100644 --- a/src/plugins/built-in/timetable/CalendarSyncControl.svelte +++ b/src/plugins/built-in/timetable/CalendarSyncControl.svelte @@ -8,6 +8,10 @@ } from "@/config/googleCalendar"; import { maybeRunDueWeeklySync } from "@/seqta/utils/googleCalendar/calendarSyncListener"; import { deleteSyncedEventsFromGoogleCalendar } from "@/seqta/utils/googleCalendar/syncEngine"; + import { + formatOutlookSyncResultMessage, + runOutlookCalendarSync, + } from "@/seqta/utils/outlookCalendar/syncRunner"; import { formatSyncResultMessage, runGoogleCalendarSync, @@ -17,17 +21,24 @@ GoogleCalendarSyncProgress, GoogleCalendarSyncResult, } from "@/seqta/utils/googleCalendar/types"; + import type { OutlookCalendarStatus } from "@/seqta/utils/outlookCalendar/types"; + import { deleteSyncedEventsFromOutlookCalendar } from "@/seqta/utils/outlookCalendar/syncEngine"; import CalendarDeleteEventsModal from "./CalendarDeleteEventsModal.svelte"; import CalendarDisconnectModal from "./CalendarDisconnectModal.svelte"; import CalendarSyncProgress from "./CalendarSyncProgress.svelte"; + import OutlookCalendarIcon from "./OutlookCalendarIcon.svelte"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { syncCalendarSyncTheme } from "./calendarSyncTheme"; + type CalendarProvider = "google" | "outlook"; type BusyPhase = "connect" | "sync" | "delete" | "disconnect" | null; + type BusyState = { provider: CalendarProvider; phase: BusyPhase } | null; - let status = $state({ configured: true, connected: false }); - let busy = $state(null); + let googleStatus = $state({ configured: true, connected: false }); + let outlookStatus = $state({ configured: true, connected: false }); + let busy = $state(null); let menuOpen = $state(false); + let modalProvider = $state(null); let showDisconnect = $state(false); let showDeleteEvents = $state(false); let toast = $state<{ message: string; error: boolean } | null>(null); @@ -42,8 +53,17 @@ let toastTimer: ReturnType | null = null; const isBusy = $derived(busy !== null); + const anyConnected = $derived(googleStatus.connected || outlookStatus.connected); const accent = "var(--bsplus-cal-accent, var(--better-main, #3b82f6))"; + function isProviderBusy(provider: CalendarProvider): boolean { + return busy?.provider === provider; + } + + function providerPhase(provider: CalendarProvider): BusyPhase { + return busy?.provider === provider ? busy.phase : null; + } + function showToastMessage(message: string, isError = false) { toast = { message, error: isError }; if (toastTimer) clearTimeout(toastTimer); @@ -53,19 +73,26 @@ } async function refreshStatus() { - status = (await browser.runtime.sendMessage({ - type: "googleCalendarStatus", - })) as GoogleCalendarStatus; - syncWeeksAhead = status.syncWeeksAhead ?? 12; - autoSyncWeekly = status.autoSyncWeekly !== false; + const [google, outlook] = await Promise.all([ + browser.runtime.sendMessage({ type: "googleCalendarStatus" }) as Promise, + browser.runtime.sendMessage({ type: "outlookCalendarStatus" }) as Promise, + ]); + googleStatus = google; + outlookStatus = outlook; + syncWeeksAhead = google.syncWeeksAhead ?? 12; + autoSyncWeekly = google.autoSyncWeekly !== false; } - async function getAccessTokenFromBackground(): Promise { - const res = (await browser.runtime.sendMessage({ - type: "googleCalendarGetAccessToken", - })) as { success?: boolean; accessToken?: string; error?: string }; + async function getAccessToken(provider: CalendarProvider): Promise { + const messageType = + provider === "google" ? "googleCalendarGetAccessToken" : "outlookCalendarGetAccessToken"; + const res = (await browser.runtime.sendMessage({ type: messageType })) as { + success?: boolean; + accessToken?: string; + error?: string; + }; if (!res?.success || !res.accessToken) { - throw new Error(res?.error ?? "Could not get Google Calendar access token."); + throw new Error(res?.error ?? "Could not get calendar access token."); } return res.accessToken; } @@ -84,15 +111,17 @@ })) as GoogleCalendarStatus & { success?: boolean }; if (result.syncWeeksAhead != null) syncWeeksAhead = result.syncWeeksAhead; if (result.autoSyncWeekly != null) autoSyncWeekly = result.autoSyncWeekly; - status = { ...status, ...result }; + googleStatus = { ...googleStatus, ...result }; } - async function performSync(mode: "full" | "incremental" = "full"): Promise { - const result = await runGoogleCalendarSync({ - mode, - onProgress: handleSyncProgress, - }); + async function performSync( + provider: CalendarProvider, + mode: "full" | "incremental" = "full", + ): Promise { + const run = provider === "google" ? runGoogleCalendarSync : runOutlookCalendarSync; + const format = provider === "google" ? formatSyncResultMessage : formatOutlookSyncResultMessage; + const result = await run({ mode, onProgress: handleSyncProgress }); syncProgress = null; if (!result.success) { @@ -100,30 +129,47 @@ return false; } - status = { - ...status, - connected: true, - lastSyncAt: result.lastSyncAt ?? status.lastSyncAt, - }; - showToastMessage(formatSyncResultMessage(result)); + if (provider === "google") { + googleStatus = { + ...googleStatus, + connected: true, + lastSyncAt: result.lastSyncAt ?? googleStatus.lastSyncAt, + }; + } else { + outlookStatus = { + ...outlookStatus, + connected: true, + lastSyncAt: result.lastSyncAt ?? outlookStatus.lastSyncAt, + }; + } + + showToastMessage(format(result)); return true; } - async function connectGoogle() { + async function connectProvider(provider: CalendarProvider) { + const status = provider === "google" ? googleStatus : outlookStatus; if (!status.configured || isBusy) return; menuOpen = true; - busy = "connect"; + busy = { provider, phase: "connect" }; + const connectType = + provider === "google" ? "googleCalendarConnect" : "outlookCalendarConnect"; try { const result = (await browser.runtime.sendMessage({ - type: "googleCalendarConnect", + type: connectType, })) as GoogleCalendarSyncResult; if (!result.success) { - showToastMessage(result.error ?? "Could not connect to Google Calendar.", true); + const label = provider === "google" ? "Google" : "Outlook"; + showToastMessage(result.error ?? `Could not connect to ${label} Calendar.`, true); return; } - status = { ...status, connected: true }; - busy = "sync"; - await performSync(); + if (provider === "google") { + googleStatus = { ...googleStatus, connected: true }; + } else { + outlookStatus = { ...outlookStatus, connected: true }; + } + busy = { provider, phase: "sync" }; + await performSync(provider); } catch (err) { showToastMessage(err instanceof Error ? err.message : "Could not connect.", true); } finally { @@ -132,16 +178,17 @@ } } - async function syncTimetable() { + async function syncProvider(provider: CalendarProvider) { + const status = provider === "google" ? googleStatus : outlookStatus; if (!status.configured || isBusy) return; if (!status.connected) { - await connectGoogle(); + await connectProvider(provider); return; } - busy = "sync"; + busy = { provider, phase: "sync" }; try { - await performSync(); + await performSync(provider); } catch (err) { showToastMessage(err instanceof Error ? err.message : "Calendar sync failed.", true); } finally { @@ -151,8 +198,9 @@ } async function confirmDeleteEvents() { - if (isBusy) return; - busy = "delete"; + if (isBusy || !modalProvider) return; + const provider = modalProvider; + busy = { provider, phase: "delete" }; syncProgress = { phase: "preparing", current: 0, @@ -160,11 +208,13 @@ message: "Preparing removal…", }; try { - const result = await deleteSyncedEventsFromGoogleCalendar( - location.origin, - getAccessTokenFromBackground, - { onProgress: handleSyncProgress }, - ); + const deleteFn = + provider === "google" + ? deleteSyncedEventsFromGoogleCalendar + : deleteSyncedEventsFromOutlookCalendar; + const result = await deleteFn(location.origin, () => getAccessToken(provider), { + onProgress: handleSyncProgress, + }); if (!result.success) { showToastMessage(result.error ?? "Could not remove calendar events.", true); @@ -174,6 +224,7 @@ const removed = result.deleted ?? 0; showDeleteEvents = false; menuOpen = false; + modalProvider = null; if (removed === 0) { showToastMessage("No synced events to remove."); } else { @@ -200,20 +251,29 @@ } async function confirmDisconnect() { - if (isBusy) return; - busy = "disconnect"; + if (isBusy || !modalProvider) return; + const provider = modalProvider; + busy = { provider, phase: "disconnect" }; + const disconnectType = + provider === "google" ? "googleCalendarDisconnect" : "outlookCalendarDisconnect"; + const label = provider === "google" ? "Google" : "Outlook"; try { const result = (await browser.runtime.sendMessage({ - type: "googleCalendarDisconnect", + type: disconnectType, })) as { success?: boolean }; if (!result?.success) { - showToastMessage("Could not disconnect Google Calendar.", true); + showToastMessage(`Could not disconnect ${label} Calendar.`, true); return; } - status = { ...status, connected: false, lastSyncAt: undefined }; + if (provider === "google") { + googleStatus = { ...googleStatus, connected: false, lastSyncAt: undefined }; + } else { + outlookStatus = { ...outlookStatus, connected: false, lastSyncAt: undefined }; + } showDisconnect = false; menuOpen = false; - showToastMessage("Disconnected from Google Calendar."); + modalProvider = null; + showToastMessage(`Disconnected from ${label} Calendar.`); } catch (err) { showToastMessage(err instanceof Error ? err.message : "Disconnect failed.", true); } finally { @@ -294,6 +354,7 @@ const themeKeys = [ "selectedColor", + "selectedFont", "DarkMode", "adaptiveThemeColour", "adaptiveThemeGradient", @@ -333,22 +394,22 @@
@@ -397,10 +458,10 @@ Calendar - {#if !status.configured} + {#if !googleStatus.configured} Not available in this build - {:else if status.connected} - Connected{formatLastSync(status.lastSyncAt) ? ` · ${formatLastSync(status.lastSyncAt)}` : ""} + {:else if googleStatus.connected} + Connected{formatLastSync(googleStatus.lastSyncAt) ? ` · ${formatLastSync(googleStatus.lastSyncAt)}` : ""} {:else} Not connected {/if} @@ -408,8 +469,129 @@
- {#if status.connected} -
+
+ {#if !googleStatus.connected} + + {:else} + + + + {/if} +
+
+ +
+
+ +
+ Outlook Calendar + + {#if !outlookStatus.configured} + Set OUTLOOK_OAUTH_CLIENT_ID to enable + {:else if outlookStatus.connected} + Connected{formatLastSync(outlookStatus.lastSyncAt) ? ` · ${formatLastSync(outlookStatus.lastSyncAt)}` : ""} + {:else} + Not connected + {/if} + +
+
+ +
+ {#if !outlookStatus.connected} + + {:else} + + + + {/if} +
+
+ + {#if anyConnected} +
-
More providers - Outlook, Apple Calendar — coming soon + Apple Calendar — coming soon
{/if} { - if (busy !== "delete") showDeleteEvents = false; + if (busy?.phase !== "delete") showDeleteEvents = false; }} onConfirm={confirmDeleteEvents} /> { - if (busy !== "disconnect") showDisconnect = false; + if (busy?.phase !== "disconnect") showDisconnect = false; }} onConfirm={confirmDisconnect} /> @@ -530,7 +665,7 @@ .bsplus-cal-sync { position: relative; display: inline-flex; - font-family: Rubik, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-family: var(--bsplus-cal-font-family, var(--betterseqta-font-family, Rubik), sans-serif); color: var(--bsplus-cal-text, var(--text-primary, #111)); } @@ -545,16 +680,19 @@ padding: 0 10px; margin-left: 4px; border-radius: 16px !important; + font-family: inherit; transition: all 0.2s ease; } .bsplus-cal-trigger-icon { + font-family: "IconFamily" !important; font-size: 16px; line-height: 1; opacity: 0.9; } .bsplus-cal-trigger-text { + font-family: inherit; font-size: 13px; font-weight: 600; line-height: 1; @@ -680,7 +818,7 @@ color: var(--bsplus-cal-text, #18181b); border: 1px solid var(--bsplus-cal-border, color-mix(in srgb, var(--bsplus-cal-text) 12%, transparent)); box-shadow: 0 16px 40px rgba(0, 0, 0, 0.22); - font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-family: var(--bsplus-cal-font-family, var(--betterseqta-font-family, Rubik), sans-serif); } .bsplus-cal-menu.dark { @@ -710,6 +848,11 @@ padding: 8px; border-radius: 10px; background: var(--bsplus-cal-surface-muted, color-mix(in srgb, var(--bsplus-cal-text) 4%, transparent)); + margin-bottom: 8px; + } + + .bsplus-cal-provider--outlook { + margin-bottom: 0; } .bsplus-cal-provider-row { diff --git a/src/plugins/built-in/timetable/OutlookCalendarIcon.svelte b/src/plugins/built-in/timetable/OutlookCalendarIcon.svelte new file mode 100644 index 00000000..23bb5e1f --- /dev/null +++ b/src/plugins/built-in/timetable/OutlookCalendarIcon.svelte @@ -0,0 +1,231 @@ + diff --git a/src/plugins/built-in/timetable/calendarSyncHost.css b/src/plugins/built-in/timetable/calendarSyncHost.css index b91b5d5e..c27f092d 100644 --- a/src/plugins/built-in/timetable/calendarSyncHost.css +++ b/src/plugins/built-in/timetable/calendarSyncHost.css @@ -20,4 +20,5 @@ .bsplus-calendar-sync-mount { display: inline-flex; + font-family: var(--bsplus-cal-font-family, var(--betterseqta-font-family, Rubik), sans-serif); } diff --git a/src/plugins/built-in/timetable/calendarSyncTheme.ts b/src/plugins/built-in/timetable/calendarSyncTheme.ts index 357ff638..a8320b20 100644 --- a/src/plugins/built-in/timetable/calendarSyncTheme.ts +++ b/src/plugins/built-in/timetable/calendarSyncTheme.ts @@ -1,5 +1,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor"; +import { ensureFontLoaded } from "@/seqta/ui/fonts/Manager"; +import { getFontPreset } from "@/seqta/ui/fonts/presets"; export const CALENDAR_THEME_CSS_VARS = [ "--better-main", @@ -42,6 +44,10 @@ function resolvePageAccentColor(): string { export function syncCalendarSyncTheme(target: HTMLElement): void { const computed = getComputedStyle(document.documentElement); const dark = isCalendarDarkMode(); + const fontPreset = getFontPreset(settingsState.selectedFont); + + ensureFontLoaded(fontPreset); + target.style.setProperty("--bsplus-cal-font-family", fontPreset.stack); for (const name of CALENDAR_THEME_CSS_VARS) { const value = diff --git a/src/plugins/built-in/timetable/calendarSyncUi.ts b/src/plugins/built-in/timetable/calendarSyncUi.ts index 45fa003a..bb39c2a6 100644 --- a/src/plugins/built-in/timetable/calendarSyncUi.ts +++ b/src/plugins/built-in/timetable/calendarSyncUi.ts @@ -1,7 +1,7 @@ import { mount, unmount } from "svelte"; import CalendarSyncControl from "./CalendarSyncControl.svelte"; import { syncCalendarSyncTheme } from "./calendarSyncTheme"; -import { registerGoogleCalendarContentHandlers } from "@/seqta/utils/googleCalendar/calendarSyncListener"; +import { registerCalendarContentHandlers } from "@/seqta/utils/googleCalendar/calendarSyncListener"; import hostStyles from "./calendarSyncHost.css?inline"; const CONTROLS_CLASS = "timetable-calendar-controls"; @@ -36,7 +36,7 @@ export async function mountGoogleCalendarButton(): Promise { if (!toolbar) return; ensureHostStyles(); - registerGoogleCalendarContentHandlers(); + registerCalendarContentHandlers(); const controls = document.createElement("div"); controls.className = `${CONTROLS_CLASS} bsplus-timetable-control`; diff --git a/src/seqta/utils/calendarSync/settings.ts b/src/seqta/utils/calendarSync/settings.ts new file mode 100644 index 00000000..ab6e1fd2 --- /dev/null +++ b/src/seqta/utils/calendarSync/settings.ts @@ -0,0 +1,65 @@ +import { + GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT, + GOOGLE_CALENDAR_SYNC_WEEKS_MAX, + GOOGLE_CALENDAR_SYNC_WEEKS_MIN, +} from "@/config/googleCalendar"; +import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage"; +import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; +import { + readSharedCalendarSyncSettings, + WEEKLY_SYNC_INTERVAL_MS, + writeSharedCalendarSyncSettings, +} from "./sharedSettings"; + +export { CALENDAR_WEEKLY_ALARM, WEEKLY_SYNC_INTERVAL_MS } from "./sharedSettings"; + +export function clampSyncWeeks(weeks: number): number { + if (!Number.isFinite(weeks)) return GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT; + return Math.min( + GOOGLE_CALENDAR_SYNC_WEEKS_MAX, + Math.max(GOOGLE_CALENDAR_SYNC_WEEKS_MIN, Math.round(weeks)), + ); +} + +export async function getSyncWeeksAhead(): Promise { + const settings = await readSharedCalendarSyncSettings(); + return clampSyncWeeks(settings.syncWeeksAhead ?? GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT); +} + +export async function getAutoSyncWeekly(): Promise { + const settings = await readSharedCalendarSyncSettings(); + return settings.autoSyncWeekly !== false; +} + +async function isAnyCalendarConnected(): Promise { + const [google, outlook] = await Promise.all([ + readGoogleCalendarState(), + readOutlookCalendarState(), + ]); + return !!( + google.refreshToken || + google.accessToken || + outlook.refreshToken || + outlook.accessToken + ); +} + +export async function shouldRunWeeklySync(): Promise { + const settings = await readSharedCalendarSyncSettings(); + if (settings.autoSyncWeekly === false) return false; + if (!(await isAnyCalendarConnected())) return false; + if (settings.pendingWeeklySync) return true; + const last = settings.lastWeeklySyncAt ?? 0; + return Date.now() - last >= WEEKLY_SYNC_INTERVAL_MS; +} + +export async function markWeeklySyncComplete(): Promise { + await writeSharedCalendarSyncSettings({ + lastWeeklySyncAt: Date.now(), + pendingWeeklySync: false, + }); +} + +export async function markWeeklySyncPending(): Promise { + await writeSharedCalendarSyncSettings({ pendingWeeklySync: true }); +} diff --git a/src/seqta/utils/calendarSync/sharedSettings.ts b/src/seqta/utils/calendarSync/sharedSettings.ts new file mode 100644 index 00000000..543728b3 --- /dev/null +++ b/src/seqta/utils/calendarSync/sharedSettings.ts @@ -0,0 +1,48 @@ +import browser from "webextension-polyfill"; +import { + GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT, +} from "@/config/googleCalendar"; +import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; + +export const BSPLUS_CALENDAR_SYNC_SETTINGS_KEY = "bsplus_calendar_sync_settings"; +export const CALENDAR_WEEKLY_ALARM = "bsplus_calendar_weekly"; +export const WEEKLY_SYNC_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; + +export interface SharedCalendarSyncSettings { + syncWeeksAhead?: number; + autoSyncWeekly?: boolean; + lastWeeklySyncAt?: number; + pendingWeeklySync?: boolean; +} + +export async function readSharedCalendarSyncSettings(): Promise { + const got = await browser.storage.local.get(BSPLUS_CALENDAR_SYNC_SETTINGS_KEY); + const raw = got[BSPLUS_CALENDAR_SYNC_SETTINGS_KEY]; + const shared = + raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as SharedCalendarSyncSettings) + : {}; + + if (Object.keys(shared).length > 0) return shared; + + const legacy = await readGoogleCalendarState(); + return { + syncWeeksAhead: legacy.syncWeeksAhead, + autoSyncWeekly: legacy.autoSyncWeekly, + lastWeeklySyncAt: legacy.lastWeeklySyncAt, + pendingWeeklySync: legacy.pendingWeeklySync, + }; +} + +export async function writeSharedCalendarSyncSettings( + patch: Partial, +): Promise { + const current = await readSharedCalendarSyncSettings(); + const next = { ...current, ...patch }; + await browser.storage.local.set({ [BSPLUS_CALENDAR_SYNC_SETTINGS_KEY]: next }); + return next; +} + +export function defaultSyncWeeksAhead(): number { + return GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT; +} diff --git a/src/seqta/utils/googleCalendar/calendarSyncListener.ts b/src/seqta/utils/googleCalendar/calendarSyncListener.ts index 5a4911e7..66daf582 100644 --- a/src/seqta/utils/googleCalendar/calendarSyncListener.ts +++ b/src/seqta/utils/googleCalendar/calendarSyncListener.ts @@ -1,28 +1,60 @@ import browser from "webextension-polyfill"; -import { shouldRunWeeklySync } from "@/seqta/utils/googleCalendar/syncSettings"; +import { + markWeeklySyncComplete, + shouldRunWeeklySync, +} from "@/seqta/utils/calendarSync/settings"; import { formatSyncResultMessage, runGoogleCalendarSync, } from "@/seqta/utils/googleCalendar/syncRunner"; +import { + formatOutlookSyncResultMessage, + runOutlookCalendarSync, +} from "@/seqta/utils/outlookCalendar/syncRunner"; +import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage"; +import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage"; import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; let listenerRegistered = false; -export function registerGoogleCalendarContentHandlers(): void { +async function runWeeklySyncForConnectedProviders(): Promise { + const [google, outlook] = await Promise.all([ + readGoogleCalendarState(), + readOutlookCalendarState(), + ]); + const results: GoogleCalendarSyncResult[] = []; + + if (google.refreshToken || google.accessToken) { + results.push(await runGoogleCalendarSync({ mode: "incremental", silent: true })); + } + if (outlook.refreshToken || outlook.accessToken) { + results.push(await runOutlookCalendarSync({ mode: "incremental", silent: true })); + } + + if (results.some((r) => r.success)) { + await markWeeklySyncComplete(); + } + + return results; +} + +export function registerCalendarContentHandlers(): void { if (listenerRegistered) return; listenerRegistered = true; browser.runtime.onMessage.addListener((request, _sender, sendResponse) => { - if (request?.type !== "googleCalendarRunWeeklySync") return false; - void runGoogleCalendarSync({ mode: "incremental", silent: true }) - .then((result: GoogleCalendarSyncResult) => sendResponse(result)) - .catch((err: unknown) => { - sendResponse({ - success: false, - error: err instanceof Error ? err.message : "Weekly sync failed", + if (request?.type === "calendarRunWeeklySync" || request?.type === "googleCalendarRunWeeklySync") { + void runWeeklySyncForConnectedProviders() + .then((results) => sendResponse({ success: true, results })) + .catch((err: unknown) => { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Weekly sync failed", + }); }); - }); - return true; + return true; + } + return false; }); } @@ -31,17 +63,38 @@ export async function maybeRunDueWeeklySync( ): Promise { if (!(await shouldRunWeeklySync())) return; - const result = await runGoogleCalendarSync({ mode: "incremental", silent: true }); + const [google, outlook] = await Promise.all([ + readGoogleCalendarState(), + readOutlookCalendarState(), + ]); + const results = await runWeeklySyncForConnectedProviders(); if (!onComplete) return; - if (!result.success) { - onComplete(result.error ?? "Weekly calendar sync failed.", true); + const errors = results.filter((r) => !r.success); + if (errors.length > 0) { + onComplete(errors[0]?.error ?? "Weekly calendar sync failed.", true); return; } - const changed = - (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0; - if (changed) { - onComplete(formatSyncResultMessage(result)); + const messages: string[] = []; + let index = 0; + if (google.refreshToken || google.accessToken) { + const result = results[index++]; + const changed = + (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0; + if (changed) messages.push(formatSyncResultMessage(result)); + } + if (outlook.refreshToken || outlook.accessToken) { + const result = results[index++]; + const changed = + (result.created ?? 0) + (result.updated ?? 0) + (result.deleted ?? 0) > 0; + if (changed) messages.push(formatOutlookSyncResultMessage(result)); + } + + if (messages.length > 0) { + onComplete(messages.join(" ")); } } + +/** @deprecated use registerCalendarContentHandlers */ +export const registerGoogleCalendarContentHandlers = registerCalendarContentHandlers; diff --git a/src/seqta/utils/googleCalendar/syncEngine.test.ts b/src/seqta/utils/googleCalendar/syncEngine.test.ts index 300dbee8..595ef9d7 100644 --- a/src/seqta/utils/googleCalendar/syncEngine.test.ts +++ b/src/seqta/utils/googleCalendar/syncEngine.test.ts @@ -15,7 +15,7 @@ jest.mock("@/seqta/utils/googleCalendar/storage", () => ({ writeGoogleCalendarState: jest.fn(async (patch: unknown) => patch), })); -jest.mock("@/seqta/utils/googleCalendar/syncSettings", () => ({ +jest.mock("@/seqta/utils/calendarSync/settings", () => ({ getSyncWeeksAhead: jest.fn(async () => 12), })); diff --git a/src/seqta/utils/googleCalendar/syncEngine.ts b/src/seqta/utils/googleCalendar/syncEngine.ts index bd12e137..bbc146c2 100644 --- a/src/seqta/utils/googleCalendar/syncEngine.ts +++ b/src/seqta/utils/googleCalendar/syncEngine.ts @@ -10,7 +10,7 @@ import { isDateInRange, syncWindowRange, } from "@/seqta/utils/googleCalendar/syncDateRange"; -import { getSyncWeeksAhead } from "@/seqta/utils/googleCalendar/syncSettings"; +import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; import { eventMapKey, readGoogleCalendarState, diff --git a/src/seqta/utils/googleCalendar/syncRunner.ts b/src/seqta/utils/googleCalendar/syncRunner.ts index d3c80543..79b23ed9 100644 --- a/src/seqta/utils/googleCalendar/syncRunner.ts +++ b/src/seqta/utils/googleCalendar/syncRunner.ts @@ -4,10 +4,7 @@ import { fetchTimetableLessons, trailingWeekRange, } from "@/seqta/utils/googleCalendar/fetchTimetable"; -import { - getSyncWeeksAhead, - markWeeklySyncComplete, -} from "@/seqta/utils/googleCalendar/syncSettings"; +import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; import { syncLessonsToGoogleCalendar } from "@/seqta/utils/googleCalendar/syncEngine"; import type { GoogleCalendarSyncOptions, @@ -63,10 +60,6 @@ export async function runGoogleCalendarSync( options, ); - if (result.success && mode === "incremental") { - await markWeeklySyncComplete(); - } - return result; } diff --git a/src/seqta/utils/googleCalendar/syncSettings.ts b/src/seqta/utils/googleCalendar/syncSettings.ts index 492685ec..3f1ab00e 100644 --- a/src/seqta/utils/googleCalendar/syncSettings.ts +++ b/src/seqta/utils/googleCalendar/syncSettings.ts @@ -1,57 +1,10 @@ -import { - GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT, - GOOGLE_CALENDAR_SYNC_WEEKS_MAX, - GOOGLE_CALENDAR_SYNC_WEEKS_MIN, -} from "@/config/googleCalendar"; -import { readGoogleCalendarState, writeGoogleCalendarState } from "./storage"; - -export const GOOGLE_CALENDAR_WEEKLY_ALARM = "bsplus_google_calendar_weekly"; -export const WEEKLY_SYNC_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; - -export function clampSyncWeeks(weeks: number): number { - if (!Number.isFinite(weeks)) return GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT; - return Math.min( - GOOGLE_CALENDAR_SYNC_WEEKS_MAX, - Math.max(GOOGLE_CALENDAR_SYNC_WEEKS_MIN, Math.round(weeks)), - ); -} - -export async function getSyncWeeksAhead(): Promise { - const state = await readGoogleCalendarState(); - return clampSyncWeeks(state.syncWeeksAhead ?? GOOGLE_CALENDAR_SYNC_WEEKS_DEFAULT); -} - -export async function setSyncWeeksAhead(weeks: number): Promise { - const syncWeeksAhead = clampSyncWeeks(weeks); - await writeGoogleCalendarState({ syncWeeksAhead }); - return syncWeeksAhead; -} - -export async function getAutoSyncWeekly(): Promise { - const state = await readGoogleCalendarState(); - return state.autoSyncWeekly !== false; -} - -export async function setAutoSyncWeekly(enabled: boolean): Promise { - await writeGoogleCalendarState({ autoSyncWeekly: enabled }); -} - -export async function shouldRunWeeklySync(): Promise { - const state = await readGoogleCalendarState(); - if (!state.refreshToken && !state.accessToken) return false; - if (state.autoSyncWeekly === false) return false; - if (state.pendingWeeklySync) return true; - const last = state.lastWeeklySyncAt ?? state.lastSyncAt ?? 0; - return Date.now() - last >= WEEKLY_SYNC_INTERVAL_MS; -} - -export async function markWeeklySyncComplete(): Promise { - await writeGoogleCalendarState({ - lastWeeklySyncAt: Date.now(), - pendingWeeklySync: false, - }); -} - -export async function markWeeklySyncPending(): Promise { - await writeGoogleCalendarState({ pendingWeeklySync: true }); -} +export { + CALENDAR_WEEKLY_ALARM as GOOGLE_CALENDAR_WEEKLY_ALARM, + WEEKLY_SYNC_INTERVAL_MS, + clampSyncWeeks, + getAutoSyncWeekly, + getSyncWeeksAhead, + markWeeklySyncComplete, + markWeeklySyncPending, + shouldRunWeeklySync, +} from "@/seqta/utils/calendarSync/settings"; diff --git a/src/seqta/utils/outlookCalendar/accountsToken.ts b/src/seqta/utils/outlookCalendar/accountsToken.ts new file mode 100644 index 00000000..7a57e916 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/accountsToken.ts @@ -0,0 +1,77 @@ +import { + OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT, + OUTLOOK_CALENDAR_REFRESH_URL, + OUTLOOK_CALENDAR_TOKEN_URL, +} from "@/config/outlookCalendar"; + +type OutlookTokenPayload = { + access_token: string; + refresh_token?: string; + expires_in?: number; +}; + +async function parseAccountsJson(res: Response): Promise> { + const text = await res.text(); + try { + return text ? (JSON.parse(text) as Record) : {}; + } catch { + return {}; + } +} + +function extractTokens(json: Record): OutlookTokenPayload { + const access_token = json.access_token; + if (typeof access_token !== "string" || !access_token) { + throw new Error("Token response missing access_token"); + } + return { + access_token, + refresh_token: typeof json.refresh_token === "string" ? json.refresh_token : undefined, + expires_in: typeof json.expires_in === "number" ? json.expires_in : undefined, + }; +} + +function formatAccountsTokenError(res: Response, json: Record): string { + if (res.status === 404 || res.status === 501) { + return OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT; + } + const err = typeof json.error === "string" ? json.error : ""; + const desc = typeof json.error_description === "string" ? json.error_description : ""; + return desc || err || `Accounts token API failed (${res.status})`; +} + +export async function exchangeOutlookCodeViaAccounts( + code: string, + redirectUri: string, + codeVerifier: string, +): Promise { + const res = await fetch(OUTLOOK_CALENDAR_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + const json = await parseAccountsJson(res); + if (!res.ok) { + throw new Error(formatAccountsTokenError(res, json)); + } + return extractTokens(json); +} + +export async function refreshOutlookTokenViaAccounts( + refreshToken: string, +): Promise { + const res = await fetch(OUTLOOK_CALENDAR_REFRESH_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + const json = await parseAccountsJson(res); + if (!res.ok) { + throw new Error(formatAccountsTokenError(res, json)); + } + return extractTokens(json); +} diff --git a/src/seqta/utils/outlookCalendar/eventMapper.ts b/src/seqta/utils/outlookCalendar/eventMapper.ts new file mode 100644 index 00000000..9fd490db --- /dev/null +++ b/src/seqta/utils/outlookCalendar/eventMapper.ts @@ -0,0 +1,36 @@ +import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar"; +import type { + GoogleCalendarEventInput, + SeqtaTimetableLesson, +} from "@/seqta/utils/googleCalendar/types"; +import { + lessonToGoogleEvent, + mapLessonsToGoogleEvents, +} from "@/seqta/utils/googleCalendar/eventMapper"; + +export { mapLessonsToGoogleEvents, lessonToGoogleEvent, seqtaLessonKey } from "@/seqta/utils/googleCalendar/eventMapper"; + +export function outlookGraphEventBody(event: GoogleCalendarEventInput): Record { + const body: Record = { + subject: event.summary, + body: { + contentType: "text", + content: event.description ?? "Synced by BetterSEQTA+", + }, + start: { dateTime: event.startDateTime, timeZone: event.timeZone }, + end: { dateTime: event.endDateTime, timeZone: event.timeZone }, + categories: [BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY], + }; + if (event.location) { + body.location = { displayName: event.location }; + } + return body; +} + +export function mapLessonsToOutlookEvents( + origin: string, + lessons: SeqtaTimetableLesson[], + timeZone: string, +): GoogleCalendarEventInput[] { + return mapLessonsToGoogleEvents(origin, lessons, timeZone); +} diff --git a/src/seqta/utils/outlookCalendar/storage.ts b/src/seqta/utils/outlookCalendar/storage.ts new file mode 100644 index 00000000..1b5f5f0a --- /dev/null +++ b/src/seqta/utils/outlookCalendar/storage.ts @@ -0,0 +1,38 @@ +import browser from "webextension-polyfill"; +import type { GoogleCalendarEventMapEntry } from "@/seqta/utils/googleCalendar/eventMapEntry"; + +export const BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY = "bsplus_outlook_calendar"; + +export interface OutlookCalendarStoredState { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + connectedAt?: number; + lastSyncAt?: number; + lastSyncOrigin?: string; + eventMap?: Record; +} + +export async function readOutlookCalendarState(): Promise { + const got = await browser.storage.local.get(BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY); + const raw = got[BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY]; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return raw as OutlookCalendarStoredState; +} + +export async function writeOutlookCalendarState( + patch: Partial, +): Promise { + const current = await readOutlookCalendarState(); + const next: OutlookCalendarStoredState = { ...current, ...patch }; + await browser.storage.local.set({ [BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY]: next }); + return next; +} + +export async function clearOutlookCalendarState(): Promise { + await browser.storage.local.remove(BSPLUS_OUTLOOK_CALENDAR_STORAGE_KEY); +} + +export function outlookEventMapKey(origin: string, seqtaKey: string): string { + return `${origin}::${seqtaKey}`; +} diff --git a/src/seqta/utils/outlookCalendar/syncEngine.ts b/src/seqta/utils/outlookCalendar/syncEngine.ts new file mode 100644 index 00000000..dd3c3815 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/syncEngine.ts @@ -0,0 +1,348 @@ +import { verboseLog } from "@/utils/verboseLog"; +import { isOutlookCalendarConfigured } from "@/config/outlookCalendar"; +import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; +import { + getStoredEventId, + lessonDateFromSeqtaKey, + normalizeEventMapEntry, +} from "@/seqta/utils/googleCalendar/eventMapEntry"; +import { + isDateInRange, + syncWindowRange, +} from "@/seqta/utils/googleCalendar/syncDateRange"; +import type { + GoogleCalendarDeleteResult, + GoogleCalendarSyncOptions, + GoogleCalendarSyncProgress, + GoogleCalendarSyncRequest, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; +import { + mapLessonsToOutlookEvents, + outlookGraphEventBody, +} from "@/seqta/utils/outlookCalendar/eventMapper"; +import { + outlookEventMapKey, + readOutlookCalendarState, + writeOutlookCalendarState, +} from "@/seqta/utils/outlookCalendar/storage"; +import { + deleteOutlookCalendarEvent, + upsertOutlookCalendarEvent, +} from "@/seqta/utils/outlookCalendar/upsertEvent"; + +const EVENT_MAP_PERSIST_EVERY = 10; + +type DeleteTrackedEventsResult = { + deleted: number; + failed: number; +}; + +function reportProgress( + onProgress: GoogleCalendarSyncOptions["onProgress"], + progress: GoogleCalendarSyncProgress, +) { + onProgress?.(progress); +} + +function lessonDateForEvent(startDateTime: string, seqtaKey: string): string { + return startDateTime.slice(0, 10) || lessonDateFromSeqtaKey(seqtaKey) || ""; +} + +async function deleteTrackedEventsFromOutlook( + entries: Array<[string, string]>, + eventMap: Record, + getAccessToken: () => Promise, + persistProgress = false, + onProgress?: GoogleCalendarSyncOptions["onProgress"], + progressOffset = 0, + progressTotal = 0, +): Promise { + if (entries.length === 0) return { deleted: 0, failed: 0 }; + + let accessToken = await getAccessToken(); + let deleted = 0; + let failed = 0; + + for (const [mapKey, eventId] of entries) { + try { + await deleteOutlookCalendarEvent(accessToken, eventId, async () => { + accessToken = await getAccessToken(); + return accessToken; + }); + delete eventMap[mapKey]; + deleted += 1; + + reportProgress(onProgress, { + phase: "deleting", + current: progressOffset + deleted + failed, + total: progressTotal, + message: `Removing old events (${deleted + failed}/${entries.length})…`, + }); + + if (persistProgress && (deleted + failed) % EVENT_MAP_PERSIST_EVERY === 0) { + await writeOutlookCalendarState({ eventMap }); + } + } catch (err) { + verboseLog("[BetterSEQTA+] Outlook Calendar event delete failed:", err); + failed += 1; + reportProgress(onProgress, { + phase: "deleting", + current: progressOffset + deleted + failed, + total: progressTotal, + message: `Removing old events (${deleted + failed}/${entries.length})…`, + }); + } + } + + return { deleted, failed }; +} + +function originEventMapEntries( + eventMap: Record, + origin: string, +): Array<[string, string]> { + const prefix = `${origin}::`; + const entries: Array<[string, string]> = []; + for (const [key, value] of Object.entries(eventMap)) { + if (!key.startsWith(prefix)) continue; + const id = getStoredEventId(value); + if (id) entries.push([key, id]); + } + return entries; +} + +function entriesToPrune( + eventMap: Record, + origin: string, + mode: "full" | "incremental", + weeksAhead: number, + currentMapKeys: Set, +): Array<[string, string]> { + const window = syncWindowRange(weeksAhead); + const prefix = `${origin}::`; + const entries: Array<[string, string]> = []; + + for (const [mapKey, raw] of Object.entries(eventMap)) { + if (!mapKey.startsWith(prefix)) continue; + const entry = normalizeEventMapEntry(raw); + if (!entry) continue; + + let shouldDelete = false; + if (mode === "incremental") { + shouldDelete = false; + } else if (entry.date) { + shouldDelete = !isDateInRange(entry.date, window); + } else { + shouldDelete = !currentMapKeys.has(mapKey); + } + + if (shouldDelete) entries.push([mapKey, entry.id]); + } + + return entries; +} + +export async function syncLessonsToOutlookCalendar( + request: GoogleCalendarSyncRequest, + getAccessToken: () => Promise, + options: GoogleCalendarSyncOptions = {}, +): Promise { + if (!isOutlookCalendarConfigured()) { + return { + success: false, + configured: false, + error: "Outlook Calendar is not configured in this extension build.", + }; + } + + const state = await readOutlookCalendarState(); + if (!state.refreshToken && !state.accessToken) { + return { success: false, configured: true, connected: false, error: "Connect Outlook Calendar first." }; + } + + const mode = request.mode ?? "full"; + const weeksAhead = request.weeksAhead ?? (await getSyncWeeksAhead()); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const events = mapLessonsToOutlookEvents(request.origin, request.lessons, timeZone); + + if (events.length === 0 && mode === "full") { + return { + success: false, + configured: true, + connected: true, + error: "No timetable classes found to sync for the selected range.", + }; + } + + reportProgress(options.onProgress, { + phase: "preparing", + current: 0, + total: Math.max(events.length, 1), + message: mode === "incremental" ? "Preparing weekly sync…" : "Preparing sync…", + }); + + let accessToken = await getAccessToken(); + const eventMap = { ...(state.eventMap ?? {}) }; + const currentMapKeys = new Set( + events.map((event) => outlookEventMapKey(request.origin, event.seqtaKey)), + ); + const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys); + const totalSteps = staleEntries.length + events.length; + + const staleResult = await deleteTrackedEventsFromOutlook( + staleEntries, + eventMap, + getAccessToken, + false, + options.onProgress, + 0, + totalSteps, + ); + + let created = 0; + let updated = 0; + let failed = staleResult.failed; + const lastSyncAt = Date.now(); + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + const mapKey = outlookEventMapKey(request.origin, event.seqtaKey); + const existingId = getStoredEventId(eventMap[mapKey]); + try { + const outlookId = await upsertOutlookCalendarEvent( + accessToken, + existingId, + outlookGraphEventBody(event), + async () => { + accessToken = await getAccessToken(); + return accessToken; + }, + ); + if (existingId) updated += 1; + else created += 1; + eventMap[mapKey] = { + id: outlookId, + date: lessonDateForEvent(event.startDateTime, event.seqtaKey), + }; + + reportProgress(options.onProgress, { + phase: "upserting", + current: staleEntries.length + i + 1, + total: totalSteps, + message: `Syncing events (${i + 1}/${events.length})…`, + }); + + if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) { + await writeOutlookCalendarState({ + eventMap, + lastSyncAt, + lastSyncOrigin: request.origin, + }); + } + } catch (err) { + verboseLog("[BetterSEQTA+] Outlook Calendar event sync failed:", err); + failed += 1; + reportProgress(options.onProgress, { + phase: "upserting", + current: staleEntries.length + i + 1, + total: totalSteps, + message: `Syncing events (${i + 1}/${events.length})…`, + }); + } + } + + if (staleResult.deleted > 0 || staleEntries.length > 0 || events.length > 0) { + await writeOutlookCalendarState({ + eventMap, + lastSyncAt, + lastSyncOrigin: request.origin, + }); + } + + reportProgress(options.onProgress, { + phase: "done", + current: totalSteps, + total: totalSteps, + message: "Sync complete", + }); + + return { + success: failed === 0, + configured: true, + connected: true, + created, + updated, + deleted: staleResult.deleted, + skipped: 0, + failed, + lastSyncAt, + error: + failed > 0 + ? `Synced with ${failed} error${failed === 1 ? "" : "s"}. Check the console for details.` + : undefined, + }; +} + +export async function deleteSyncedEventsFromOutlookCalendar( + origin: string, + getAccessToken: () => Promise, + options: GoogleCalendarSyncOptions = {}, +): Promise { + if (!isOutlookCalendarConfigured()) { + return { + success: false, + configured: false, + error: "Outlook Calendar is not configured in this extension build.", + }; + } + + const state = await readOutlookCalendarState(); + if (!state.refreshToken && !state.accessToken) { + return { success: false, configured: true, connected: false, error: "Connect Outlook Calendar first." }; + } + + const entries = originEventMapEntries(state.eventMap ?? {}, origin); + if (entries.length === 0) { + return { success: true, configured: true, connected: true, deleted: 0, failed: 0 }; + } + + reportProgress(options.onProgress, { + phase: "preparing", + current: 0, + total: entries.length, + message: "Preparing removal…", + }); + + const eventMap = { ...(state.eventMap ?? {}) }; + const { deleted, failed } = await deleteTrackedEventsFromOutlook( + entries, + eventMap, + getAccessToken, + true, + options.onProgress, + 0, + entries.length, + ); + + await writeOutlookCalendarState({ eventMap }); + + reportProgress(options.onProgress, { + phase: "done", + current: entries.length, + total: entries.length, + message: "Removal complete", + }); + + return { + success: failed === 0, + configured: true, + connected: true, + deleted, + failed, + error: + failed > 0 + ? `Removed ${deleted} event${deleted === 1 ? "" : "s"} with ${failed} error${failed === 1 ? "" : "s"}.` + : undefined, + }; +} diff --git a/src/seqta/utils/outlookCalendar/syncRunner.ts b/src/seqta/utils/outlookCalendar/syncRunner.ts new file mode 100644 index 00000000..4758f642 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/syncRunner.ts @@ -0,0 +1,76 @@ +import browser from "webextension-polyfill"; +import { + fetchTimetableForSync, + fetchTimetableLessons, + trailingWeekRange, +} from "@/seqta/utils/googleCalendar/fetchTimetable"; +import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; +import { syncLessonsToOutlookCalendar } from "@/seqta/utils/outlookCalendar/syncEngine"; +import type { + GoogleCalendarSyncOptions, + GoogleCalendarSyncProgress, + GoogleCalendarSyncResult, +} from "@/seqta/utils/googleCalendar/types"; + +export type OutlookCalendarRunMode = "full" | "incremental"; + +export interface RunOutlookCalendarSyncParams { + mode?: OutlookCalendarRunMode; + silent?: boolean; + onProgress?: (progress: GoogleCalendarSyncProgress) => void; +} + +async function getAccessTokenFromBackground(): Promise { + const res = (await browser.runtime.sendMessage({ + type: "outlookCalendarGetAccessToken", + })) as { success?: boolean; accessToken?: string; error?: string }; + if (!res?.success || !res.accessToken) { + throw new Error(res?.error ?? "Could not get Outlook Calendar access token."); + } + return res.accessToken; +} + +export async function runOutlookCalendarSync( + params: RunOutlookCalendarSyncParams = {}, +): Promise { + const mode = params.mode ?? "full"; + const weeksAhead = await getSyncWeeksAhead(); + + params.onProgress?.({ + phase: "preparing", + current: 0, + total: 1, + message: mode === "incremental" ? "Fetching new week…" : "Fetching timetable…", + }); + + const lessons = + mode === "incremental" + ? await fetchTimetableLessons(trailingWeekRange(weeksAhead)) + : await fetchTimetableForSync(weeksAhead); + + const options: GoogleCalendarSyncOptions = { onProgress: params.onProgress }; + const result = await syncLessonsToOutlookCalendar( + { + origin: location.origin, + lessons, + mode, + weeksAhead, + }, + getAccessTokenFromBackground, + options, + ); + + return result; +} + +export function formatOutlookSyncResultMessage(result: GoogleCalendarSyncResult): string { + const created = result.created ?? 0; + const updated = result.updated ?? 0; + const deleted = result.deleted ?? 0; + const parts: string[] = []; + if (created > 0) parts.push(`${created} new`); + if (updated > 0) parts.push(`${updated} updated`); + if (deleted > 0) parts.push(`${deleted} removed`); + if (parts.length === 0) return "Outlook Calendar is up to date."; + return `Outlook Calendar updated (${parts.join(", ")}).`; +} diff --git a/src/seqta/utils/outlookCalendar/types.ts b/src/seqta/utils/outlookCalendar/types.ts new file mode 100644 index 00000000..75b1a23c --- /dev/null +++ b/src/seqta/utils/outlookCalendar/types.ts @@ -0,0 +1,6 @@ +export interface OutlookCalendarStatus { + configured: boolean; + connected: boolean; + lastSyncAt?: number; + lastSyncOrigin?: string; +} diff --git a/src/seqta/utils/outlookCalendar/upsertEvent.test.ts b/src/seqta/utils/outlookCalendar/upsertEvent.test.ts new file mode 100644 index 00000000..410075e0 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/upsertEvent.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { upsertOutlookCalendarEvent, deleteOutlookCalendarEvent } from "./upsertEvent"; + +const fetchMock = jest.fn(); +global.fetch = fetchMock as unknown as typeof fetch; + +describe("upsertOutlookCalendarEvent", () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + it("creates a new event when no existing id", async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: "evt-1" }), + }); + + const id = await upsertOutlookCalendarEvent("token", undefined, { subject: "Math" }); + expect(id).toBe("evt-1"); + expect(fetchMock).toHaveBeenCalledWith( + "https://graph.microsoft.com/v1.0/me/events", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("patches when an existing id is provided", async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); + + const id = await upsertOutlookCalendarEvent("token", "evt-1", { subject: "Math" }); + expect(id).toBe("evt-1"); + expect(fetchMock).toHaveBeenCalledWith( + "https://graph.microsoft.com/v1.0/me/events/evt-1", + expect.objectContaining({ method: "PATCH" }), + ); + }); +}); + +describe("deleteOutlookCalendarEvent", () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + it("treats 404 as success", async () => { + fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) }); + await expect(deleteOutlookCalendarEvent("token", "evt-1")).resolves.toBeUndefined(); + }); +}); diff --git a/src/seqta/utils/outlookCalendar/upsertEvent.ts b/src/seqta/utils/outlookCalendar/upsertEvent.ts new file mode 100644 index 00000000..1c5fc182 --- /dev/null +++ b/src/seqta/utils/outlookCalendar/upsertEvent.ts @@ -0,0 +1,68 @@ +import { OUTLOOK_GRAPH_API } from "@/config/outlookCalendar"; + +export async function upsertOutlookCalendarEvent( + accessToken: string, + existingEventId: string | undefined, + body: Record, + refreshAccessToken?: () => Promise, +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }; + + if (existingEventId) { + const res = await fetch(`${OUTLOOK_GRAPH_API}/me/events/${encodeURIComponent(existingEventId)}`, { + method: "PATCH", + headers, + body: JSON.stringify(body), + }); + if (res.status === 401 && refreshAccessToken) { + const nextToken = await refreshAccessToken(); + return upsertOutlookCalendarEvent(nextToken, existingEventId, body); + } + if (res.ok) return existingEventId; + if (res.status !== 404) { + const err = (await res.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + throw new Error(err?.error?.message ?? `Outlook Calendar update failed (${res.status})`); + } + } + + const res = await fetch(`${OUTLOOK_GRAPH_API}/me/events`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + if (res.status === 401 && refreshAccessToken) { + const nextToken = await refreshAccessToken(); + return upsertOutlookCalendarEvent(nextToken, undefined, body); + } + const json = (await res.json().catch(() => ({}))) as { + id?: string; + error?: { message?: string }; + }; + if (!res.ok || !json.id) { + throw new Error(json?.error?.message ?? `Outlook Calendar create failed (${res.status})`); + } + return json.id; +} + +export async function deleteOutlookCalendarEvent( + accessToken: string, + eventId: string, + refreshAccessToken?: () => Promise, +): Promise { + const res = await fetch(`${OUTLOOK_GRAPH_API}/me/events/${encodeURIComponent(eventId)}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (res.status === 401 && refreshAccessToken) { + const nextToken = await refreshAccessToken(); + return deleteOutlookCalendarEvent(nextToken, eventId); + } + if (res.ok || res.status === 404 || res.status === 410) return; + const err = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; + throw new Error(err?.error?.message ?? `Outlook Calendar delete failed (${res.status})`); +} diff --git a/vite.config.ts b/vite.config.ts index c85800cd..59573f87 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -80,6 +80,7 @@ export default defineConfig(({ command, mode: viteMode }) => { __UPDATE_CHANNEL__: JSON.stringify(process.env.UPDATE_CHANNEL ?? "stable"), __BUILD_LABEL__: JSON.stringify(process.env.BUILD_LABEL ?? ""), __GOOGLE_OAUTH_CLIENT_ID__: JSON.stringify(env.GOOGLE_OAUTH_CLIENT_ID ?? ""), + __OUTLOOK_OAUTH_CLIENT_ID__: JSON.stringify(env.OUTLOOK_OAUTH_CLIENT_ID ?? ""), }, envDir: repoRoot, plugins: [