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
+5
View File
@@ -0,0 +1,5 @@
# Optional override for Google OAuth client ID at build/dev time.
# Client secret is NOT used in the extension — token exchange is on accounts.betterseqta.org.
# See docs/GOOGLE_CALENDAR_ACCOUNTS_CALLBACK.md
# GOOGLE_OAUTH_CLIENT_ID=your-id.apps.googleusercontent.com
+3
View File
@@ -13,6 +13,7 @@ import {
withSuppressedCloudAutoUpload,
} from "./background/cloudSettingsAutoSync";
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
import { registerGoogleCalendarMessageHandlers } from "./background/googleCalendar";
/**
* Session-only dev-mode override of the content API base.
@@ -557,6 +558,8 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
},
};
registerGoogleCalendarMessageHandlers(MESSAGE_HANDLERS, isTrustedSender);
browser.runtime.onMessage.addListener(
// @ts-ignore - OnMessageListener expects literal true for async, we return boolean
(request: any, sender: browser.Runtime.MessageSender, sendResponse: MessageSender) => {
+301
View File
@@ -0,0 +1,301 @@
import browser from "webextension-polyfill";
import {
GOOGLE_AUTH_URL,
GOOGLE_CALENDAR_OAUTH_CALLBACK,
GOOGLE_CALENDAR_SCOPE,
GOOGLE_OAUTH_CLIENT_ID,
googleOAuthRedirectUriHint,
isGoogleCalendarConfigured,
} from "@/config/googleCalendar";
import {
exchangeGoogleCodeViaAccounts,
refreshGoogleTokenViaAccounts,
} from "@/seqta/utils/googleCalendar/accountsToken";
import {
clearGoogleCalendarState,
readGoogleCalendarState,
writeGoogleCalendarState,
} from "@/seqta/utils/googleCalendar/storage";
import type {
GoogleCalendarStatus,
GoogleCalendarSyncResult,
} from "@/seqta/utils/googleCalendar/types";
function base64UrlEncode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
async function sha256(input: string): Promise<ArrayBuffer> {
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<string> {
return base64UrlEncode(await sha256(verifier));
}
function parseRedirectCode(responseUrl: string): string {
const url = new URL(responseUrl);
const err = url.searchParams.get("error");
if (err) throw new Error(url.searchParams.get("error_description") ?? err);
const code = url.searchParams.get("code");
if (!code) throw new Error("Google sign-in did not return an authorization code.");
return code;
}
const GOOGLE_OAUTH_CALLBACK_PREFIX = GOOGLE_CALENDAR_OAUTH_CALLBACK;
const GOOGLE_OAUTH_TIMEOUT_MS = 10 * 60 * 1000;
function isGoogleOAuthCallbackUrl(url: string): boolean {
if (!url.startsWith(GOOGLE_OAUTH_CALLBACK_PREFIX)) return false;
const parsed = new URL(url);
return parsed.searchParams.has("code") || parsed.searchParams.has("error");
}
/** Tab-based OAuth (same pattern as cloud login) — launchWebAuthFlow does not reliably return external redirect URLs. */
function waitForGoogleOAuthCallback(authTabId: number): Promise<string> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
cleanup();
reject(new Error("Google sign-in timed out. Close the tab and try again."));
}, GOOGLE_OAUTH_TIMEOUT_MS);
const cleanup = () => {
clearTimeout(timeoutId);
browser.tabs.onUpdated.removeListener(onUpdated);
browser.tabs.onRemoved.removeListener(onRemoved);
};
const finishFromUrl = (url: string, tabId: number) => {
if (!isGoogleOAuthCallbackUrl(url)) return false;
cleanup();
void browser.tabs.remove(tabId).catch(() => {});
resolve(url);
return true;
};
const onRemoved = (tabId: number) => {
if (tabId !== authTabId) return;
cleanup();
reject(new Error("Google sign-in was cancelled."));
};
const onUpdated = (
tabId: number,
changeInfo: browser.Tabs.OnUpdatedChangeInfoType,
tab: browser.Tabs.Tab,
) => {
if (tabId !== authTabId) return;
const url =
changeInfo.url ?? (changeInfo.status === "complete" ? tab.url : undefined);
if (url) finishFromUrl(url, tabId);
};
browser.tabs.onUpdated.addListener(onUpdated);
browser.tabs.onRemoved.addListener(onRemoved);
void browser.tabs
.get(authTabId)
.then((tab) => {
if (tab.url) finishFromUrl(tab.url, authTabId);
})
.catch(() => {});
});
}
async function openGoogleOAuthTab(authUrl: string): Promise<string> {
const tab = await browser.tabs.create({ url: authUrl, active: true });
if (tab.id === undefined) {
throw new Error("Could not open Google sign-in tab.");
}
return waitForGoogleOAuthCallback(tab.id);
}
async function getValidAccessToken(): Promise<string> {
const state = await readGoogleCalendarState();
const now = Date.now();
if (state.accessToken && state.expiresAt && state.expiresAt > now + 60_000) {
return state.accessToken;
}
if (!state.refreshToken) {
throw new Error("Not connected to Google Calendar.");
}
const refreshed = await refreshGoogleTokenViaAccounts(state.refreshToken);
const expiresAt = refreshed.expires_in
? Date.now() + refreshed.expires_in * 1000
: Date.now() + 3_600_000;
await writeGoogleCalendarState({
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token ?? state.refreshToken,
expiresAt,
});
return refreshed.access_token;
}
async function connectGoogleCalendar(): Promise<GoogleCalendarSyncResult> {
if (!isGoogleCalendarConfigured()) {
return {
success: false,
configured: false,
error: "Google Calendar is not configured in this extension build.",
};
}
const redirectUri = GOOGLE_CALENDAR_OAUTH_CALLBACK;
const verifier = randomVerifier();
const challenge = await pkceChallenge(verifier);
const authUrl = new URL(GOOGLE_AUTH_URL);
authUrl.searchParams.set("client_id", GOOGLE_OAUTH_CLIENT_ID);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("scope", GOOGLE_CALENDAR_SCOPE);
authUrl.searchParams.set("access_type", "offline");
authUrl.searchParams.set("prompt", "consent");
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
let responseUrl: string;
try {
responseUrl = await openGoogleOAuthTab(authUrl.toString());
} catch (err) {
const message = err instanceof Error ? err.message : "Google sign-in failed";
const mismatch =
/redirect_uri_mismatch|invalid_request/i.test(message) ||
/redirect_uri_mismatch|invalid_request/i.test(String(err));
return {
success: false,
configured: true,
error: mismatch
? `Google redirect URI mismatch. ${googleOAuthRedirectUriHint()}`
: message.includes("cancel")
? "Google sign-in was cancelled."
: message,
};
}
try {
const code = parseRedirectCode(responseUrl);
const tokens = await exchangeGoogleCodeViaAccounts(code, redirectUri, verifier);
const expiresAt = tokens.expires_in
? Date.now() + tokens.expires_in * 1000
: Date.now() + 3_600_000;
const existing = await readGoogleCalendarState();
await writeGoogleCalendarState({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token ?? existing.refreshToken,
expiresAt,
connectedAt: Date.now(),
});
return { success: true, configured: true, connected: true };
} catch (err) {
return {
success: false,
configured: true,
error: err instanceof Error ? err.message : "Google sign-in failed",
};
}
}
async function getGoogleCalendarStatus(): Promise<GoogleCalendarStatus> {
const state = await readGoogleCalendarState();
return {
configured: isGoogleCalendarConfigured(),
connected: !!(state.refreshToken || state.accessToken),
lastSyncAt: state.lastSyncAt,
lastSyncOrigin: state.lastSyncOrigin,
};
}
export async function handleGoogleCalendarConnect(): Promise<GoogleCalendarSyncResult> {
return connectGoogleCalendar();
}
export async function handleGoogleCalendarDisconnect(): Promise<{ success: boolean }> {
await clearGoogleCalendarState();
return { success: true };
}
export async function handleGoogleCalendarStatus(): Promise<GoogleCalendarStatus> {
return getGoogleCalendarStatus();
}
export function registerGoogleCalendarMessageHandlers(
handlers: Record<
string,
(
request: unknown,
sendResponse: (response?: unknown) => void,
sender?: browser.Runtime.MessageSender,
) => boolean | void
>,
isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean,
): void {
const rejectUntrusted = (
sendResponse: (response?: unknown) => void,
sender?: browser.Runtime.MessageSender,
): boolean => {
if (isTrustedSender(sender)) return false;
sendResponse({ success: false, error: "Unauthorized sender" });
return true;
};
handlers.googleCalendarConnect = (_req, sendResponse, sender) => {
if (rejectUntrusted(sendResponse, sender)) return false;
void handleGoogleCalendarConnect()
.then(sendResponse)
.catch((err) => {
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Google sign-in failed",
});
});
return true;
};
handlers.googleCalendarDisconnect = (_req, sendResponse, sender) => {
if (rejectUntrusted(sendResponse, sender)) return false;
void handleGoogleCalendarDisconnect()
.then(sendResponse)
.catch((err) => {
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Disconnect failed",
});
});
return true;
};
handlers.googleCalendarStatus = (_req, sendResponse, sender) => {
if (rejectUntrusted(sendResponse, sender)) return false;
void handleGoogleCalendarStatus()
.then(sendResponse)
.catch(() => {
sendResponse({ configured: isGoogleCalendarConfigured(), connected: false });
});
return true;
};
handlers.googleCalendarGetAccessToken = (_req, sendResponse, sender) => {
if (rejectUntrusted(sendResponse, sender)) return false;
void getValidAccessToken()
.then((accessToken) => sendResponse({ success: true, accessToken }))
.catch((err) => {
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Token refresh failed",
});
});
return true;
};
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Google Calendar OAuth — public client config (extension).
* Client secret and token exchange live on accounts.betterseqta.org.
* See docs/GOOGLE_CALENDAR_ACCOUNTS_CALLBACK.md
*/
const HARDCODED_GOOGLE_OAUTH_CLIENT_ID =
"270834969641-f6t7jtpu6j0cemse8updj3rkos7nl0hf.apps.googleusercontent.com";
const envClientId =
typeof __GOOGLE_OAUTH_CLIENT_ID__ !== "undefined" ? __GOOGLE_OAUTH_CLIENT_ID__ : "";
export const GOOGLE_OAUTH_CLIENT_ID: string =
envClientId.trim() || HARDCODED_GOOGLE_OAUTH_CLIENT_ID.trim();
export const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
/** Must match Google Console + accounts callback route exactly. */
export const GOOGLE_CALENDAR_OAUTH_CALLBACK = `${ACCOUNTS_BASE}/auth/google/calendar/callback`;
export const GOOGLE_CALENDAR_TOKEN_URL = `${ACCOUNTS_BASE}/api/bsplus/google/calendar/token`;
export const GOOGLE_CALENDAR_REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/google/calendar/refresh`;
export const GOOGLE_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.events";
export const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
export const GOOGLE_CALENDAR_API = "https://www.googleapis.com/calendar/v3";
export const BSPLUS_GOOGLE_CALENDAR_EVENT_PROP = "bsplusSeqtaKey";
/** Weeks of timetable to sync (from start of current week). */
export const GOOGLE_CALENDAR_SYNC_WEEKS = 12;
export function isGoogleCalendarConfigured(): boolean {
return GOOGLE_OAUTH_CLIENT_ID.length > 0;
}
export const GOOGLE_CALENDAR_ACCOUNTS_NOT_READY_HINT =
"Google Calendar connect requires accounts.betterseqta.org — see docs/GOOGLE_CALENDAR_ACCOUNTS_CALLBACK.md";
export function googleOAuthRedirectUriHint(): string {
return `Authorized redirect URI in Google Cloud Console must be: ${GOOGLE_CALENDAR_OAUTH_CALLBACK}`;
}
+1
View File
@@ -2,3 +2,4 @@ declare const __ENABLE_GH_RELEASE_UPDATE_CHECK__: boolean;
declare const __GH_RELEASE_REPO__: string;
declare const __UPDATE_CHANNEL__: "stable" | "nightly";
declare const __BUILD_LABEL__: string;
declare const __GOOGLE_OAUTH_CLIENT_ID__: string;
+2 -1
View File
@@ -2,6 +2,7 @@ import "./index.css";
import Settings from "./pages/settings.svelte";
import IconFamily from "@/resources/fonts/IconFamily.woff";
import browser from "webextension-polyfill";
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
import renderSvelte from "./main";
import { initializeSettingsState } from "@/seqta/utils/listeners/SettingsState";
@@ -13,7 +14,7 @@ function InjectCustomIcons() {
style.innerHTML = `
@font-face {
font-family: 'IconFamily';
src: url('${browser.runtime.getURL(IconFamily)}') format('woff');
src: url('${resolveExtensionAssetUrl(IconFamily)}') format('woff');
font-weight: normal;
font-style: normal;
}`;
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "@jest/globals";
import { resolveExtensionAssetUrl } from "./extensionAssetUrl";
describe("resolveExtensionAssetUrl", () => {
it("returns already-resolved extension URLs unchanged", () => {
const url =
"chrome-extension://abc/assets/IconFamily-B8lopphU.woff";
expect(resolveExtensionAssetUrl(url)).toBe(url);
});
it("repairs accidental double extension URL prefix", () => {
const doubled =
"chrome-extension://abc/chrome-extension://abc/assets/IconFamily-B8lopphU.woff";
expect(resolveExtensionAssetUrl(doubled)).toBe(
"chrome-extension://abc/assets/IconFamily-B8lopphU.woff",
);
});
});
+18
View File
@@ -0,0 +1,18 @@
import browser from "webextension-polyfill";
/** Vite asset imports are often already absolute extension URLs in production bundles. */
export function resolveExtensionAssetUrl(importedUrl: string): string {
if (!importedUrl) return importedUrl;
const doublePrefix = importedUrl.match(
/^((?:chrome|moz)-extension:\/\/[^/]+)\/\1\/(.+)$/,
);
if (doublePrefix) {
return `${doublePrefix[1]}/${doublePrefix[2]}`;
}
if (/^(chrome-extension|moz-extension|https?|data):/.test(importedUrl)) {
return importedUrl;
}
return browser.runtime.getURL(importedUrl.replace(/^\/+/, ""));
}
+13 -4
View File
@@ -15,17 +15,25 @@
"64": "resources/icons/icon-64.png"
}
},
"permissions": ["tabs", "notifications", "storage"],
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
"permissions": ["tabs", "notifications", "storage", "identity"],
"host_permissions": [
"https://newsapi.org/",
"https://betterseqta.org/",
"https://accounts.betterseqta.org/",
"https://www.googleapis.com/",
"https://oauth2.googleapis.com/",
"*://*/*"
],
"background": {
"service_worker": "background.ts"
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org"
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org https://www.googleapis.com https://oauth2.googleapis.com"
},
"content_scripts": [
{
"matches": ["*://*/*"],
"exclude_matches": ["*://accounts.betterseqta.org/*"],
"js": ["SEQTA.ts"],
"run_at": "document_start"
}
@@ -36,7 +44,8 @@
"resources/icons/*",
"resources/update-image.webp",
"resources/pdfjs/pdf.worker.min.mjs",
"resources/pdfjs/pdf.legacy.min.mjs"
"resources/pdfjs/pdf.legacy.min.mjs",
"assets/*"
],
"matches": ["*://*/*"]
}
@@ -0,0 +1,140 @@
<script lang="ts">
import { fade } from "svelte/transition";
let {
open = false,
busy = false,
onConfirm,
onCancel,
} = $props<{
open?: boolean;
busy?: boolean;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
}>();
</script>
{#if open}
<div
class="bsplus-cal-modal-backdrop"
onclick={(e) => {
if (e.target === e.currentTarget && !busy) onCancel();
}}
onkeydown={(e) => {
if (e.key === "Escape" && !busy) onCancel();
}}
role="presentation"
transition:fade={{ duration: 150 }}
>
<div
class="bsplus-cal-modal"
role="dialog"
aria-modal="true"
aria-labelledby="bsplus-cal-disconnect-title"
transition:fade={{ duration: 180 }}
>
<h2 id="bsplus-cal-disconnect-title" class="bsplus-cal-modal-title">
Disconnect Google Calendar?
</h2>
<p class="bsplus-cal-modal-body">
Your synced timetable events will stay in Google Calendar, but BetterSEQTA+ will stop
updating them until you connect again.
</p>
<div class="bsplus-cal-modal-actions">
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--ghost"
disabled={busy}
onclick={onCancel}
>
Cancel
</button>
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--danger"
disabled={busy}
onclick={() => void onConfirm()}
>
{busy ? "Disconnecting…" : "Disconnect"}
</button>
</div>
</div>
</div>
{/if}
<style>
.bsplus-cal-modal-backdrop {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.bsplus-cal-modal {
width: min(100%, 400px);
padding: 20px;
border-radius: 16px;
background: var(--bsplus-cal-surface, #fff);
color: var(--bsplus-cal-text, #111);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.22);
border: 1px solid color-mix(in srgb, var(--bsplus-cal-text, #111) 12%, transparent);
}
.bsplus-cal-modal-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
line-height: 1.3;
}
.bsplus-cal-modal-body {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.5;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 72%, transparent);
}
.bsplus-cal-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.bsplus-cal-btn {
padding: 8px 14px;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.bsplus-cal-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.bsplus-cal-btn--ghost {
background: color-mix(in srgb, var(--bsplus-cal-text, #111) 8%, transparent);
color: var(--bsplus-cal-text, #111);
}
.bsplus-cal-btn--ghost:hover:not(:disabled) {
background: color-mix(in srgb, var(--bsplus-cal-text, #111) 14%, transparent);
}
.bsplus-cal-btn--danger {
background: #dc2626;
color: #fff;
}
.bsplus-cal-btn--danger:hover:not(:disabled) {
background: #b91c1c;
}
</style>
@@ -0,0 +1,705 @@
<script lang="ts">
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 CalendarDisconnectModal from "./CalendarDisconnectModal.svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { syncCalendarSyncTheme } from "./calendarSyncTheme";
type BusyPhase = "connect" | "sync" | "disconnect" | null;
let status = $state<GoogleCalendarStatus>({ configured: true, connected: false });
let busy = $state<BusyPhase>(null);
let menuOpen = $state(false);
let showDisconnect = $state(false);
let toast = $state<{ message: string; error: boolean } | null>(null);
let rootEl = $state<HTMLDivElement | null>(null);
let triggerEl = $state<HTMLButtonElement | null>(null);
let menuEl = $state<HTMLDivElement | null>(null);
let menuStyle = $state("");
let toastTimer: ReturnType<typeof setTimeout> | null = null;
const isBusy = $derived(busy !== null);
const accent = "var(--bsplus-cal-accent, var(--better-main, #3b82f6))";
function showToastMessage(message: string, isError = false) {
toast = { message, error: isError };
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast = null;
}, 4500);
}
async function refreshStatus() {
status = (await browser.runtime.sendMessage({
type: "googleCalendarStatus",
})) as GoogleCalendarStatus;
}
async function getAccessTokenFromBackground(): Promise<string> {
const res = (await browser.runtime.sendMessage({
type: "googleCalendarGetAccessToken",
})) as { success?: boolean; accessToken?: string; error?: string };
if (!res?.success || !res.accessToken) {
throw new Error(res?.error ?? "Could not get Google Calendar access token.");
}
return res.accessToken;
}
async function performSync(): Promise<boolean> {
const lessons = await fetchTimetableForSync();
const result = await syncLessonsToGoogleCalendar(
{ origin: location.origin, lessons },
getAccessTokenFromBackground,
);
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).`);
return true;
}
async function connectGoogle() {
if (!status.configured || isBusy) return;
menuOpen = true;
busy = "connect";
try {
const result = (await browser.runtime.sendMessage({
type: "googleCalendarConnect",
})) as GoogleCalendarSyncResult;
if (!result.success) {
showToastMessage(result.error ?? "Could not connect to Google Calendar.", true);
return;
}
status = { ...status, connected: true };
busy = "sync";
await performSync();
} catch (err) {
showToastMessage(err instanceof Error ? err.message : "Could not connect.", true);
} finally {
busy = null;
}
}
async function syncTimetable() {
if (!status.configured || isBusy) return;
if (!status.connected) {
await connectGoogle();
return;
}
busy = "sync";
try {
await performSync();
} catch (err) {
showToastMessage(err instanceof Error ? err.message : "Calendar sync failed.", true);
} finally {
busy = null;
}
}
async function confirmDisconnect() {
if (isBusy) return;
busy = "disconnect";
try {
const result = (await browser.runtime.sendMessage({
type: "googleCalendarDisconnect",
})) as { success?: boolean };
if (!result?.success) {
showToastMessage("Could not disconnect Google Calendar.", true);
return;
}
status = { ...status, connected: false, lastSyncAt: undefined };
showDisconnect = false;
menuOpen = false;
showToastMessage("Disconnected from Google Calendar.");
} catch (err) {
showToastMessage(err instanceof Error ? err.message : "Disconnect failed.", true);
} finally {
busy = null;
}
}
function toggleMenu() {
if (isBusy) return;
menuOpen = !menuOpen;
if (menuOpen) {
queueMicrotask(() => syncMenuTheme());
}
}
function formatLastSync(ts?: number): string | null {
if (!ts) return null;
const diff = Date.now() - ts;
if (diff < 60_000) return "Synced just now";
if (diff < 3_600_000) return `Synced ${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `Synced ${Math.floor(diff / 3_600_000)}h ago`;
return `Synced ${new Date(ts).toLocaleDateString()}`;
}
function updateMenuPosition() {
if (!triggerEl) return;
const rect = triggerEl.getBoundingClientRect();
menuStyle = `top:${rect.bottom + 8}px;right:${window.innerWidth - rect.right}px;`;
syncMenuTheme();
}
function syncMenuTheme() {
if (!menuEl) return;
syncCalendarSyncTheme(menuEl);
}
function syncMountedTheme() {
const themeHost = rootEl?.closest(".bsplus-calendar-sync-mount") as HTMLElement | null;
if (themeHost) syncCalendarSyncTheme(themeHost);
if (menuOpen) syncMenuTheme();
}
function portalMenu(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
},
};
}
$effect(() => {
const host = rootEl?.closest(".timetable-calendar-controls");
host?.classList.toggle("bsplus-cal-menu-open", menuOpen);
return () => host?.classList.remove("bsplus-cal-menu-open");
});
$effect(() => {
if (!menuOpen || !menuEl) return;
syncMenuTheme();
updateMenuPosition();
const onLayout = () => updateMenuPosition();
window.addEventListener("resize", onLayout);
window.addEventListener("scroll", onLayout, true);
return () => {
window.removeEventListener("resize", onLayout);
window.removeEventListener("scroll", onLayout, true);
};
});
onMount(() => {
void refreshStatus();
const themeKeys = [
"selectedColor",
"DarkMode",
"adaptiveThemeColour",
"adaptiveThemeGradient",
"selectedTheme",
] as const;
const onThemeChange = () => syncMountedTheme();
for (const key of themeKeys) {
settingsState.register(key, onThemeChange);
}
const themeObserver = new MutationObserver(onThemeChange);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style", "class"],
});
const onDocPointer = (event: PointerEvent) => {
if (!menuOpen) return;
const target = event.target as Node;
if (rootEl?.contains(target)) return;
if (menuEl?.contains(target)) return;
menuOpen = false;
};
document.addEventListener("pointerdown", onDocPointer);
return () => {
document.removeEventListener("pointerdown", onDocPointer);
for (const key of themeKeys) {
settingsState.unregister(key, onThemeChange);
}
themeObserver.disconnect();
if (toastTimer) clearTimeout(toastTimer);
};
});
</script>
<div class="bsplus-cal-sync" bind:this={rootEl}>
<button
type="button"
class="bsplus-cal-trigger"
bind:this={triggerEl}
class:bsplus-cal-trigger--open={menuOpen}
class:bsplus-cal-trigger--connected={status.connected}
class:bsplus-cal-trigger--busy={isBusy}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-busy={isBusy}
aria-label={status.connected ? "Google Calendar sync options" : "Connect Google Calendar"}
onclick={() => {
if (!isBusy) toggleMenu();
}}
>
<span class="bsplus-cal-trigger-label" aria-hidden="true">
<span class="bsplus-google-word">
<span class="bsplus-google-g">G</span><span class="bsplus-google-o1">o</span><span class="bsplus-google-o2">o</span><span class="bsplus-google-g2">g</span><span class="bsplus-google-l">l</span><span class="bsplus-google-e">e</span>
</span>
<span class="bsplus-cal-word">Calendar</span>
</span>
{#if status.connected}
<span class="bsplus-cal-status-dot" aria-hidden="true"></span>
{/if}
<span class="bsplus-cal-chevron" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.94a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</span>
{#if isBusy}
<span class="bsplus-cal-spinner" aria-hidden="true"></span>
{/if}
</button>
{#if menuOpen}
<div
class="bsplus-cal-menu"
role="menu"
bind:this={menuEl}
style={menuStyle}
use:portalMenu
transition:fly={{ y: -6, duration: 160 }}
>
<div class="bsplus-cal-menu-header">
<span class="bsplus-cal-menu-title">Calendar sync</span>
<span class="bsplus-cal-menu-sub">Connect providers to sync your timetable</span>
</div>
<div class="bsplus-cal-provider" role="none">
<div class="bsplus-cal-provider-row">
<span class="bsplus-cal-provider-icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22 12c0-.96-.08-1.88-.24-2.76H12v5.22h5.68c-.24 1.28-.96 2.44-2.04 3.18v2.64h3.3c1.92-1.76 3.06-4.36 3.06-7.28z"
/>
<path
fill="#34A853"
d="M12 22c2.76 0 5.08-.92 6.78-2.5l-3.3-2.64c-.92.62-2.1.98-3.48.98-2.68 0-4.96-1.8-5.78-4.22H2.18v2.72A10 10 0 0 0 12 22z"
/>
<path
fill="#FBBC05"
d="M6.22 13.62A5.98 5.98 0 0 1 5.82 12c0-.56.1-1.1.28-1.62V7.66H2.18A10 10 0 0 0 2 12c0 1.62.38 3.16 1.06 4.52l3.16-2.9z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.5 0 2.84.52 3.9 1.54l2.92-2.92C17.08 2.34 14.76 1.2 12 1.2 7.54 1.2 3.72 3.94 2.18 7.66l4.04 3.14c.82-2.42 3.1-4.22 5.78-4.22z"
/>
</svg>
</span>
<div class="bsplus-cal-provider-copy">
<span class="bsplus-cal-provider-name">
<span class="bsplus-google-word bsplus-google-word--sm">
<span class="bsplus-google-g">G</span><span class="bsplus-google-o1">o</span><span class="bsplus-google-o2">o</span><span class="bsplus-google-g2">g</span><span class="bsplus-google-l">l</span><span class="bsplus-google-e">e</span>
</span>
<span> Calendar</span>
</span>
<span class="bsplus-cal-provider-status">
{#if !status.configured}
Not available in this build
{:else if status.connected}
Connected{formatLastSync(status.lastSyncAt) ? ` · ${formatLastSync(status.lastSyncAt)}` : ""}
{:else}
Not connected
{/if}
</span>
</div>
</div>
<div class="bsplus-cal-provider-actions">
{#if !status.connected}
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--primary"
style:--bsplus-cal-accent={accent}
role="menuitem"
disabled={!status.configured || isBusy}
onclick={() => void connectGoogle()}
>
{busy === "connect" ? "Connecting…" : "Connect"}
</button>
{:else}
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--primary"
style:--bsplus-cal-accent={accent}
role="menuitem"
disabled={isBusy}
onclick={() => void syncTimetable()}
>
{busy === "sync" ? "Syncing…" : "Sync now"}
</button>
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--ghost"
role="menuitem"
disabled={isBusy}
onclick={() => {
showDisconnect = true;
}}
>
Disconnect
</button>
{/if}
</div>
</div>
<div class="bsplus-cal-coming-soon" role="none">
<span class="bsplus-cal-coming-soon-label">More providers</span>
<span class="bsplus-cal-coming-soon-hint">Outlook, Apple Calendar — coming soon</span>
</div>
</div>
{/if}
<CalendarDisconnectModal
open={showDisconnect}
busy={busy === "disconnect"}
onCancel={() => {
if (busy !== "disconnect") showDisconnect = false;
}}
onConfirm={confirmDisconnect}
/>
{#if toast}
<div
class="bsplus-cal-toast"
class:bsplus-cal-toast--error={toast.error}
role="status"
transition:fade={{ duration: 150 }}
>
{toast.message}
</div>
{/if}
</div>
<style>
.bsplus-cal-sync {
position: relative;
display: inline-flex;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--bsplus-cal-text, var(--text-primary, #111));
}
.bsplus-cal-trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 188px;
height: 42px;
padding: 0 12px 0 14px;
border: 1px solid color-mix(in srgb, var(--bsplus-cal-text, #111) 14%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--bsplus-cal-surface, #fff) 88%, transparent);
color: var(--bsplus-cal-text, var(--text-primary, #111));
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.bsplus-cal-trigger-label {
display: inline-flex;
align-items: baseline;
gap: 6px;
font-size: 17px;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1;
white-space: nowrap;
}
.bsplus-google-word {
display: inline-flex;
font-size: 18px;
font-weight: 700;
letter-spacing: -0.02em;
}
.bsplus-google-word--sm {
font-size: 13px;
}
.bsplus-google-g,
.bsplus-google-g2 {
color: #4285f4;
}
.bsplus-google-o1,
.bsplus-google-e {
color: #ea4335;
}
.bsplus-google-o2 {
color: #fbbc05;
}
.bsplus-google-l {
color: #34a853;
}
.bsplus-cal-word {
color: var(--bsplus-cal-text, var(--text-primary, #111));
font-weight: 700;
}
.bsplus-cal-trigger:hover:not(.bsplus-cal-trigger--busy) {
transform: scale(1.03);
border-color: color-mix(in srgb, var(--bsplus-cal-accent, var(--better-main, #3b82f6)) 45%, transparent);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.bsplus-cal-trigger:active:not(.bsplus-cal-trigger--busy) {
transform: scale(0.97);
}
.bsplus-cal-trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--bsplus-cal-surface, #fff),
0 0 0 4px var(--bsplus-cal-accent, var(--better-main, #3b82f6));
}
.bsplus-cal-trigger--open {
border-color: color-mix(in srgb, var(--bsplus-cal-accent, var(--better-main, #3b82f6)) 55%, transparent);
background: color-mix(in srgb, var(--bsplus-cal-accent, var(--better-main, #3b82f6)) 10%, transparent);
}
.bsplus-cal-trigger--connected {
border-color: color-mix(in srgb, #22c55e 50%, transparent);
}
.bsplus-cal-trigger--busy {
opacity: 0.85;
cursor: wait;
}
.bsplus-cal-status-dot {
position: absolute;
top: 7px;
right: 26px;
width: 7px;
height: 7px;
border-radius: 999px;
background: #22c55e;
box-shadow: 0 0 0 2px var(--bsplus-cal-surface, #fff);
}
.bsplus-cal-chevron {
display: flex;
flex: 0 0 auto;
width: 16px;
height: 16px;
margin-left: auto;
opacity: 0.6;
pointer-events: none;
}
.bsplus-cal-chevron svg {
width: 100%;
height: 100%;
}
.bsplus-cal-spinner {
position: absolute;
right: 10px;
width: 16px;
height: 16px;
border-radius: 999px;
border: 2px solid color-mix(in srgb, var(--bsplus-cal-accent, #3b82f6) 25%, transparent);
border-top-color: var(--bsplus-cal-accent, #3b82f6);
animation: bsplus-cal-spin 0.7s linear infinite;
}
@keyframes bsplus-cal-spin {
to {
transform: rotate(360deg);
}
}
.bsplus-cal-menu {
position: fixed;
z-index: 2147483647;
width: min(320px, calc(100vw - 24px));
padding: 10px;
border-radius: 14px;
background: var(--bsplus-cal-surface, #fff);
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;
}
.bsplus-cal-menu.dark {
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
}
.bsplus-cal-menu-header {
padding: 6px 8px 10px;
border-bottom: 1px solid var(--bsplus-cal-border, color-mix(in srgb, var(--bsplus-cal-text) 10%, transparent));
margin-bottom: 8px;
}
.bsplus-cal-menu-title {
display: block;
font-size: 13px;
font-weight: 700;
}
.bsplus-cal-menu-sub {
display: block;
margin-top: 2px;
font-size: 11px;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 62%, transparent);
}
.bsplus-cal-provider {
padding: 8px;
border-radius: 10px;
background: var(--bsplus-cal-surface-muted, color-mix(in srgb, var(--bsplus-cal-text) 4%, transparent));
}
.bsplus-cal-provider-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.bsplus-cal-provider-icon {
display: flex;
flex: 0 0 auto;
width: 28px;
height: 28px;
}
.bsplus-cal-provider-icon svg {
width: 100%;
height: 100%;
}
.bsplus-cal-provider-copy {
min-width: 0;
}
.bsplus-cal-provider-name {
display: block;
font-size: 13px;
font-weight: 600;
}
.bsplus-cal-provider-status {
display: block;
margin-top: 1px;
font-size: 11px;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 62%, transparent);
}
.bsplus-cal-provider-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.bsplus-cal-action {
flex: 1 1 auto;
min-width: 0;
padding: 7px 10px;
border: none;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.bsplus-cal-action:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.bsplus-cal-action--primary {
background: var(--bsplus-cal-accent, var(--better-main, #3b82f6));
color: #fff;
}
.bsplus-cal-action--primary:hover:not(:disabled) {
filter: brightness(1.06);
transform: scale(1.02);
}
.bsplus-cal-action--ghost {
background: var(--bsplus-cal-surface-muted, color-mix(in srgb, var(--bsplus-cal-text) 8%, transparent));
color: var(--bsplus-cal-text, #18181b);
}
.bsplus-cal-action--ghost:hover:not(:disabled) {
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;
bottom: 16px;
z-index: 100000;
max-width: min(360px, calc(100vw - 32px));
padding: 12px 14px;
border-radius: 12px;
background: rgba(20, 20, 20, 0.92);
color: #fff;
font-size: 13px;
line-height: 1.4;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
pointer-events: none;
}
.bsplus-cal-toast--error {
background: rgba(120, 24, 24, 0.95);
}
</style>
@@ -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;
}
@@ -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)",
);
}
@@ -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<typeof mount> | 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<void> {
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();
}
+3
View File
@@ -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<void> {
handleTimetableZoom();
handleTimetableAssessmentHide();
void mountGoogleCalendarButton();
}
function handleTimetableZoom(): void {
+3 -2
View File
@@ -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;
}`;
@@ -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;
}
+12 -3
View File
@@ -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 }) => ({
},
},
},
}));
};
});