From eae2e0587cf3c3f99352f9110ec45f4cc173440a Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sat, 27 Jun 2026 14:19:28 +0930 Subject: [PATCH] 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} +