From 174f47f8c201c09ec71cdd307d7d1cd2b06c83c1 Mon Sep 17 00:00:00 2001 From: AdoLovesChicken Date: Fri, 26 Jun 2026 20:59:33 +1000 Subject: [PATCH 1/7] feat(GradeRangeSlider): add typeable min/max inputs - Add inputs alongside the slider so values can be typed directly, not just dragged - Clamp typed/dragged values to [min, max] - Animate the slider with a sine ease when a value is typed, keeping the drag unanimated - Meet min/max values instead of letting fields swap mid-keystroke - Combine min/max handlers into one `onInput` function --- .../gradeAnalytics/GradeRangeSlider.svelte | 163 +++++++++++++++--- .../built-in/gradeAnalytics/styles.css | 68 +++++++- 2 files changed, 198 insertions(+), 33 deletions(-) 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/CalendarSyncControl.svelte b/src/plugins/built-in/timetable/CalendarSyncControl.svelte new file mode 100644 index 00000000..7499b3a2 --- /dev/null +++ b/src/plugins/built-in/timetable/CalendarSyncControl.svelte @@ -0,0 +1,705 @@ + + +
+ + + {#if menuOpen} + + {/if} + + { + if (busy !== "disconnect") showDisconnect = false; + }} + onConfirm={confirmDisconnect} + /> + + {#if toast} +
+ {toast.message} +
+ {/if} +
+ + diff --git a/src/plugins/built-in/timetable/calendarSyncHost.css b/src/plugins/built-in/timetable/calendarSyncHost.css new file mode 100644 index 00000000..b91b5d5e --- /dev/null +++ b/src/plugins/built-in/timetable/calendarSyncHost.css @@ -0,0 +1,23 @@ +.timetablepage #toolbar { + position: relative; + z-index: 100; +} + +.timetable-calendar-controls { + position: relative; + z-index: 100001; + display: inline-flex; + align-items: center; +} + +.timetable-calendar-controls.bsplus-cal-menu-open { + z-index: 2147483646; +} + +.timetablepage #toolbar:has(.bsplus-cal-menu-open) { + z-index: 2147483646 !important; +} + +.bsplus-calendar-sync-mount { + display: inline-flex; +} diff --git a/src/plugins/built-in/timetable/calendarSyncTheme.ts b/src/plugins/built-in/timetable/calendarSyncTheme.ts new file mode 100644 index 00000000..357ff638 --- /dev/null +++ b/src/plugins/built-in/timetable/calendarSyncTheme.ts @@ -0,0 +1,82 @@ +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; +import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor"; + +export const CALENDAR_THEME_CSS_VARS = [ + "--better-main", + "--better-pale", + "--better-light", + "--text-color", + "--background-primary", + "--background-secondary", + "--text-primary", + "--theme-offset-bg", + "--better-sub", +] as const; + +const ACCENT_CSS_VARS = [ + "--better-main", + "--accent-color-value", + "--accentColor", + "--colour-betterseqta-blue", +] as const; + +export function isCalendarDarkMode(): boolean { + return !!settingsState.DarkMode || document.documentElement.classList.contains("dark"); +} + +function resolvePageAccentColor(): string { + const computed = getComputedStyle(document.documentElement); + for (const name of ACCENT_CSS_VARS) { + const solid = extractSolidColor(computed.getPropertyValue(name)); + if (solid) return solid; + } + const fromSettings = settingsState.selectedColor?.trim(); + if (fromSettings) { + const solid = extractSolidColor(fromSettings); + if (solid) return solid; + } + return "#3b82f6"; +} + +/** Sync extension theme (including dark mode) onto a calendar UI host or portaled menu. */ +export function syncCalendarSyncTheme(target: HTMLElement): void { + const computed = getComputedStyle(document.documentElement); + const dark = isCalendarDarkMode(); + + for (const name of CALENDAR_THEME_CSS_VARS) { + const value = + document.documentElement.style.getPropertyValue(name).trim() || + computed.getPropertyValue(name).trim(); + if (value) target.style.setProperty(name, value); + } + + const accent = resolvePageAccentColor(); + target.style.setProperty("--bsplus-cal-accent", accent); + target.style.setProperty("--better-main", accent); + target.classList.toggle("dark", dark); + + const textPrimary = + computed.getPropertyValue("--text-primary").trim() || + computed.getPropertyValue("--text-color").trim(); + const bgPrimary = + computed.getPropertyValue("--background-primary").trim() || + computed.getPropertyValue("--background-secondary").trim() || + computed.getPropertyValue("--theme-offset-bg").trim(); + + target.style.setProperty( + "--bsplus-cal-text", + textPrimary || (dark ? "#f4f4f5" : "#18181b"), + ); + target.style.setProperty( + "--bsplus-cal-surface", + bgPrimary || (dark ? "#27272a" : "#ffffff"), + ); + target.style.setProperty( + "--bsplus-cal-surface-muted", + dark ? "#3f3f46" : "color-mix(in srgb, var(--bsplus-cal-text) 5%, var(--bsplus-cal-surface))", + ); + target.style.setProperty( + "--bsplus-cal-border", + dark ? "color-mix(in srgb, #ffffff 14%, transparent)" : "color-mix(in srgb, var(--bsplus-cal-text) 12%, transparent)", + ); +} diff --git a/src/plugins/built-in/timetable/calendarSyncUi.ts b/src/plugins/built-in/timetable/calendarSyncUi.ts new file mode 100644 index 00000000..a1059bb4 --- /dev/null +++ b/src/plugins/built-in/timetable/calendarSyncUi.ts @@ -0,0 +1,53 @@ +import { mount, unmount } from "svelte"; +import CalendarSyncControl from "./CalendarSyncControl.svelte"; +import { syncCalendarSyncTheme } from "./calendarSyncTheme"; +import hostStyles from "./calendarSyncHost.css?inline"; + +const CONTROLS_CLASS = "timetable-calendar-controls"; +const HOST_STYLE_ID = "bsplus-calendar-sync-host-styles"; + +let currentApp: ReturnType | null = null; +let mountRoot: HTMLElement | null = null; + +function ensureHostStyles() { + if (document.getElementById(HOST_STYLE_ID)) return; + const style = document.createElement("style"); + style.id = HOST_STYLE_ID; + style.textContent = hostStyles; + document.head.appendChild(style); +} + +function teardown() { + if (currentApp) { + unmount(currentApp); + currentApp = null; + } + + mountRoot = null; + document.querySelector(`.${CONTROLS_CLASS}`)?.remove(); + document.getElementById(HOST_STYLE_ID)?.remove(); +} + +export async function mountGoogleCalendarButton(): Promise { + if (document.querySelector(`.${CONTROLS_CLASS}`)) return; + + const toolbar = document.getElementById("toolbar"); + if (!toolbar) return; + + ensureHostStyles(); + + const controls = document.createElement("div"); + controls.className = `${CONTROLS_CLASS} bsplus-timetable-control`; + toolbar.appendChild(controls); + + mountRoot = document.createElement("div"); + mountRoot.className = "bsplus-calendar-sync-mount"; + syncCalendarSyncTheme(mountRoot); + controls.appendChild(mountRoot); + + currentApp = mount(CalendarSyncControl, { target: mountRoot }); +} + +export function unmountGoogleCalendarButton(): void { + teardown(); +} diff --git a/src/plugins/built-in/timetable/index.ts b/src/plugins/built-in/timetable/index.ts index fb135116..1bdc3cb9 100644 --- a/src/plugins/built-in/timetable/index.ts +++ b/src/plugins/built-in/timetable/index.ts @@ -2,6 +2,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import type { Plugin } from "../../core/types"; import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat"; import { waitForElm } from "@/seqta/utils/waitForElm"; +import { mountGoogleCalendarButton, unmountGoogleCalendarButton } from "./calendarSyncUi"; const timetablePlugin: Plugin<{}, {}> = { id: "timetable", @@ -26,6 +27,7 @@ const timetablePlugin: Plugin<{}, {}> = { const hideControls = document.querySelector(".timetable-hide-controls"); if (hideControls) hideControls.remove(); + unmountGoogleCalendarButton(); resetTimetableStyles(); } }; @@ -81,6 +83,7 @@ async function handleTimetable(): Promise { handleTimetableZoom(); handleTimetableAssessmentHide(); + void mountGoogleCalendarButton(); } function handleTimetableZoom(): void { diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 6e150cb8..165a3f88 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -35,6 +35,7 @@ import { observeMenuItemPosition } from "@/seqta/utils/sidebarMenuIcons"; // Icons and fonts import IconFamily from "@/resources/fonts/IconFamily.woff"; +import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; // Stylesheets import iframeCSS from "@/css/iframe.scss?raw"; @@ -106,7 +107,7 @@ export async function finishLoad() { } export function GetCSSElement(file: string) { - const cssFile = browser.runtime.getURL(file); + const cssFile = resolveExtensionAssetUrl(file); const fileref = document.createElement("link"); fileref.setAttribute("rel", "stylesheet"); fileref.setAttribute("type", "text/css"); @@ -814,7 +815,7 @@ function InjectCustomIcons() { style.innerHTML = ` @font-face { font-family: 'IconFamily'; - src: url('${browser.runtime.getURL(IconFamily)}') format('woff'); + src: url('${resolveExtensionAssetUrl(IconFamily)}') format('woff'); font-weight: normal; font-style: normal; }`; diff --git a/src/seqta/utils/Loaders/LoadEngageHomePage.ts b/src/seqta/utils/Loaders/LoadEngageHomePage.ts index e4c57b5c..647cd881 100644 --- a/src/seqta/utils/Loaders/LoadEngageHomePage.ts +++ b/src/seqta/utils/Loaders/LoadEngageHomePage.ts @@ -1,6 +1,7 @@ import { animate } from "motion"; import browser from "webextension-polyfill"; import LogoLight from "@/resources/icons/betterseqta-light-icon.png"; +import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour"; import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat"; import debounce from "@/seqta/utils/debounce"; @@ -129,7 +130,7 @@ function renderEngageDayLessons(): void { if (lessons.length === 0) { dayContainer.innerHTML = `
- +

No lessons for this day.

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

${message}

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

${message}

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

No assessments available.

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

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

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

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

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

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

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

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