feat: add Google Calendar timetable sync via accounts OAuth

OAuth flows through accounts.betterseqta.org with token exchange server-side. Sync runs in the content script to avoid MV3 service worker timeouts. Adds Svelte calendar sync UI on the timetable page, extension asset URL helper, and excludes calendar tokens from cloud sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-27 11:51:48 +09:30
parent bd13042fe4
commit e6b8be6821
29 changed files with 1984 additions and 17 deletions
@@ -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 = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
<img src="${resolveExtensionAssetUrl(LogoLight)}" alt="" />
<p>No lessons for this day.</p>
</div>`;
} else {
@@ -714,7 +715,7 @@ function showEngageTimetableError(message: string): void {
dayContainer.classList.remove("loading");
dayContainer.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
<img src="${resolveExtensionAssetUrl(LogoLight)}" alt="" />
<p>${message}</p>
</div>`;
}
@@ -725,7 +726,7 @@ function showEngageNoticesSectionError(message: string): void {
noticeContainer.classList.remove("loading");
noticeContainer.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
<img src="${resolveExtensionAssetUrl(LogoLight)}" alt="" />
<p>${message}</p>
</div>`;
}
+3 -2
View File
@@ -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 = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" />
<img src="${resolveExtensionAssetUrl(LogoLight)}" />
<p>No assessments available.</p>
</div>`;
}
+3 -2
View File
@@ -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%;
`;
+2
View File
@@ -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<string>([
@@ -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<Record<string, unknown>> {
const text = await res.text();
try {
return text ? (JSON.parse(text) as Record<string, unknown>) : {};
} catch {
return {};
}
}
function extractTokens(json: Record<string, unknown>): 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, unknown>): 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<GoogleTokenPayload> {
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<GoogleTokenPayload> {
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);
}
@@ -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);
});
});
@@ -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<string>();
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<string, unknown> {
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,
},
},
};
}
@@ -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<T>(path: string, body: Record<string, unknown>): Promise<T> {
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<number | undefined> {
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<SeqtaTimetableLesson[]> {
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<string, unknown> = { 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 : [];
}
+39
View File
@@ -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<string, string>;
}
export async function readGoogleCalendarState(): Promise<GoogleCalendarStoredState> {
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<GoogleCalendarStoredState>,
): Promise<GoogleCalendarStoredState> {
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<void> {
await browser.storage.local.remove(BSPLUS_GOOGLE_CALENDAR_STORAGE_KEY);
}
export function eventMapKey(origin: string, seqtaKey: string): string {
return `${origin}::${seqtaKey}`;
}
@@ -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<string>,
): Promise<GoogleCalendarSyncResult> {
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;
}
+47
View File
@@ -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;
}
@@ -0,0 +1,44 @@
import { GOOGLE_CALENDAR_API } from "@/config/googleCalendar";
export async function upsertGoogleCalendarEvent(
accessToken: string,
calendarId: string,
existingEventId: string | undefined,
body: Record<string, unknown>,
refreshAccessToken?: () => Promise<string>,
): Promise<string> {
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;
}