From b830f53069bc78affd65fca03f76b4ebab9e3683 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Thu, 20 Aug 2026 08:22:41 +0930 Subject: [PATCH] feat: background resuming for calendar syncing --- .../built-in/timetable/calendarSyncUi.ts | 2 - src/plugins/built-in/timetable/index.ts | 3 + src/seqta/utils/calendarSync/settings.ts | 33 +++++++++ src/seqta/utils/calendarSync/syncRunner.ts | 72 ++++++++++++++----- .../utils/cloudSettingsSync.legacy.test.ts | 2 + src/seqta/utils/cloudSettingsSync.ts | 3 + .../googleCalendar/calendarSyncListener.ts | 11 ++- 7 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/plugins/built-in/timetable/calendarSyncUi.ts b/src/plugins/built-in/timetable/calendarSyncUi.ts index e87fb0b6..7f2b97fd 100644 --- a/src/plugins/built-in/timetable/calendarSyncUi.ts +++ b/src/plugins/built-in/timetable/calendarSyncUi.ts @@ -3,7 +3,6 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor"; import { ensureFontLoaded } from "@/seqta/ui/fonts/Manager"; import { getFontPreset } from "@/seqta/ui/fonts/presets"; -import { registerCalendarContentHandlers } from "@/seqta/utils/googleCalendar/calendarSyncListener"; import type { GoogleCalendarSyncProgress } from "@/seqta/utils/googleCalendar/types"; import hostStyles from "./calendarSyncHost.css?inline"; @@ -152,7 +151,6 @@ export async function mountGoogleCalendarButton(): Promise { if (!toolbar) return; ensureHostStyles(); - registerCalendarContentHandlers(); const controls = document.createElement("div"); controls.className = CONTROLS_CLASS; diff --git a/src/plugins/built-in/timetable/index.ts b/src/plugins/built-in/timetable/index.ts index d761c91b..ad109f23 100644 --- a/src/plugins/built-in/timetable/index.ts +++ b/src/plugins/built-in/timetable/index.ts @@ -5,6 +5,7 @@ import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat"; import { waitForElm } from "@/seqta/utils/waitForElm"; import { verboseLog } from "@/utils/verboseLog"; import { mountGoogleCalendarButton, unmountGoogleCalendarButton } from "./calendarSyncUi"; +import { registerCalendarContentHandlers } from "@/seqta/utils/googleCalendar/calendarSyncListener"; const timetablePlugin: Plugin<{}, {}> = { id: "timetable", @@ -15,6 +16,8 @@ const timetablePlugin: Plugin<{}, {}> = { disableToggle: true, run: async (api) => { + registerCalendarContentHandlers(); + const { unregister } = api.seqta.onMount(".timetablepage", handleTimetable); return () => { diff --git a/src/seqta/utils/calendarSync/settings.ts b/src/seqta/utils/calendarSync/settings.ts index 5294cf62..b67fdfd5 100644 --- a/src/seqta/utils/calendarSync/settings.ts +++ b/src/seqta/utils/calendarSync/settings.ts @@ -10,6 +10,8 @@ import { } from "@/seqta/utils/calendarSync/providerStorage"; export const BSPLUS_CALENDAR_SYNC_SETTINGS_KEY = "bsplus_calendar_sync_settings"; +/** Device-local interrupted sync marker — never cloud-synced. */ +export const BSPLUS_CALENDAR_SYNC_IN_PROGRESS_KEY = "bsplus_calendar_sync_in_progress"; export const CALENDAR_WEEKLY_ALARM = "bsplus_calendar_weekly"; export const WEEKLY_SYNC_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; @@ -98,3 +100,34 @@ export async function markWeeklySyncComplete(): Promise { export async function markWeeklySyncPending(): Promise { await writeSharedCalendarSyncSettings({ pendingWeeklySync: true }); } + +type PendingCalendarSync = { + provider: "google" | "outlook"; + mode: "full" | "incremental"; + origin: string; + startedAt: number; +}; + +/** Returns a pending sync worth resuming, or clears and returns null. */ +export async function readResumableCalendarSync(): Promise { + const got = await browser.storage.local.get(BSPLUS_CALENDAR_SYNC_IN_PROGRESS_KEY); + const raw = got[BSPLUS_CALENDAR_SYNC_IN_PROGRESS_KEY]; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + + const pending = raw as PendingCalendarSync; + const validProvider = pending.provider === "google" || pending.provider === "outlook"; + const validMode = pending.mode === "full" || pending.mode === "incremental"; + const fresh = Date.now() - pending.startedAt < 24 * 60 * 60 * 1000; + + if ( + !validProvider || + !validMode || + pending.origin !== location.origin || + !fresh + ) { + await browser.storage.local.remove(BSPLUS_CALENDAR_SYNC_IN_PROGRESS_KEY); + return null; + } + + return pending; +} diff --git a/src/seqta/utils/calendarSync/syncRunner.ts b/src/seqta/utils/calendarSync/syncRunner.ts index eee4e045..15673e4b 100644 --- a/src/seqta/utils/calendarSync/syncRunner.ts +++ b/src/seqta/utils/calendarSync/syncRunner.ts @@ -9,7 +9,15 @@ import { } from "@/seqta/utils/calendarSync/syncEngine"; import { reportSyncProgress } from "@/seqta/utils/calendarSync/lessonSyncShared"; import type { OutlookCalendarStatus } from "@/seqta/utils/calendarSync/providerStorage"; -import { getSyncWeeksAhead } from "@/seqta/utils/calendarSync/settings"; +import { + readGoogleCalendarState, + readOutlookCalendarState, +} from "@/seqta/utils/calendarSync/providerStorage"; +import { + BSPLUS_CALENDAR_SYNC_IN_PROGRESS_KEY, + getSyncWeeksAhead, + readResumableCalendarSync, +} from "@/seqta/utils/calendarSync/settings"; import type { GoogleCalendarStatus, GoogleCalendarSyncProgress, @@ -98,24 +106,56 @@ export async function runCalendarSync( const mode = params.mode ?? "full"; const weeksAhead = await getSyncWeeksAhead(); - reportSyncProgress(params.onProgress, { - phase: "preparing", - current: 0, - total: 1, - message: mode === "incremental" ? "Fetching new week…" : "Fetching timetable…", + await browser.storage.local.set({ + [BSPLUS_CALENDAR_SYNC_IN_PROGRESS_KEY]: { + provider: config.provider, + mode, + origin: location.origin, + startedAt: Date.now(), + }, }); - const lessons = - mode === "incremental" - ? await fetchTimetableLessons(trailingWeekRange(weeksAhead)) - : await fetchTimetableForSync(weeksAhead); + try { + reportSyncProgress(params.onProgress, { + phase: "preparing", + current: 0, + total: 1, + message: mode === "incremental" ? "Fetching new week…" : "Fetching timetable…", + }); - return syncLessonsToCalendar( - config.lessonSyncProvider, - { origin: location.origin, lessons, mode, weeksAhead }, - () => getCalendarAccessToken(config.provider), - { onProgress: params.onProgress }, - ); + const lessons = + mode === "incremental" + ? await fetchTimetableLessons(trailingWeekRange(weeksAhead)) + : await fetchTimetableForSync(weeksAhead); + + return await syncLessonsToCalendar( + config.lessonSyncProvider, + { origin: location.origin, lessons, mode, weeksAhead }, + () => getCalendarAccessToken(config.provider), + { onProgress: params.onProgress }, + ); + } finally { + await browser.storage.local.remove(BSPLUS_CALENDAR_SYNC_IN_PROGRESS_KEY); + } +} + +export async function resumeInterruptedCalendarSync( + params: RunCalendarSyncParams = {}, +): Promise { + const pending = await readResumableCalendarSync(); + if (!pending) return null; + + const state = + pending.provider === "google" + ? await readGoogleCalendarState() + : await readOutlookCalendarState(); + if (!state.refreshToken && !state.accessToken) { + await browser.storage.local.remove(BSPLUS_CALENDAR_SYNC_IN_PROGRESS_KEY); + return null; + } + + const config = pending.provider === "google" ? GOOGLE_SYNC_RUNNER : OUTLOOK_SYNC_RUNNER; + return runCalendarSync(config, { ...params, mode: pending.mode }); } export const runGoogleCalendarSync = (params?: RunCalendarSyncParams) => diff --git a/src/seqta/utils/cloudSettingsSync.legacy.test.ts b/src/seqta/utils/cloudSettingsSync.legacy.test.ts index 423c917b..6de7436e 100644 --- a/src/seqta/utils/cloudSettingsSync.legacy.test.ts +++ b/src/seqta/utils/cloudSettingsSync.legacy.test.ts @@ -4,6 +4,7 @@ import { normalizeThemeIdForSync, resolveThemeIdForPostSyncDownload, } from "./cloudSettingsSync"; +import { BSPLUS_CALENDAR_SYNC_IN_PROGRESS_KEY } from "@/seqta/utils/calendarSync/settings"; describe("migrateLegacyToPluginSettings", () => { it("maps animatedbk without overwriting existing plugin fields", () => { @@ -30,6 +31,7 @@ describe("isKeyIncludedInCloudUploadPayload", () => { it("excludes auth and device cache prefixes", () => { expect(isKeyIncludedInCloudUploadPayload("bsplus_token")).toBe(false); expect(isKeyIncludedInCloudUploadPayload("bsplus_install_id")).toBe(false); + expect(isKeyIncludedInCloudUploadPayload(BSPLUS_CALENDAR_SYNC_IN_PROGRESS_KEY)).toBe(false); expect(isKeyIncludedInCloudUploadPayload("plugin.global-search.storage.index")).toBe( false, ); diff --git a/src/seqta/utils/cloudSettingsSync.ts b/src/seqta/utils/cloudSettingsSync.ts index 6c0b8c0b..965df51d 100644 --- a/src/seqta/utils/cloudSettingsSync.ts +++ b/src/seqta/utils/cloudSettingsSync.ts @@ -1,5 +1,6 @@ import browser from "webextension-polyfill"; import isEqual from "@/seqta/utils/isEqual"; +import { BSPLUS_CALENDAR_SYNC_IN_PROGRESS_KEY } from "@/seqta/utils/calendarSync/settings"; /** Matches the contract in docs/CLOUD_SETTINGS_SYNC_SERVER.md */ export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1; @@ -42,6 +43,7 @@ export const KEYS_OMITTED_FROM_CLOUD_UPLOAD = [ "bsplus_install_id", /** Pending feedback ids awaiting a reply notification — device-local. */ "bsplus_pending_feedback_ids", + BSPLUS_CALENDAR_SYNC_IN_PROGRESS_KEY, ] as const; /** @@ -64,6 +66,7 @@ const CLIENT_ONLY_CLOUD_KEYS_EXACT = [ BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY, "bsplus_lastCloudPoll", BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY, + BSPLUS_CALENDAR_SYNC_IN_PROGRESS_KEY, ] as const; /** After restoring from cloud, keep local session so the user stays signed in. */ diff --git a/src/seqta/utils/googleCalendar/calendarSyncListener.ts b/src/seqta/utils/googleCalendar/calendarSyncListener.ts index 0135dc32..51ddfdb7 100644 --- a/src/seqta/utils/googleCalendar/calendarSyncListener.ts +++ b/src/seqta/utils/googleCalendar/calendarSyncListener.ts @@ -4,7 +4,11 @@ import { shouldRunWeeklySync, } from "@/seqta/utils/calendarSync/settings"; import { formatLessonSyncResultMessage } from "@/seqta/utils/calendarSync/lessonSyncShared"; -import { runGoogleCalendarSync, runOutlookCalendarSync } from "@/seqta/utils/calendarSync/syncRunner"; +import { + resumeInterruptedCalendarSync, + runGoogleCalendarSync, + runOutlookCalendarSync, +} from "@/seqta/utils/calendarSync/syncRunner"; import { readGoogleCalendarState, readOutlookCalendarState, @@ -12,6 +16,7 @@ import { import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types"; let listenerRegistered = false; +let resumeChecked = false; const WEEKLY_PROVIDERS = [ { label: "Google Calendar", read: readGoogleCalendarState, run: runGoogleCalendarSync }, @@ -44,6 +49,10 @@ async function runWeeklySyncForConnectedProviders(): Promise< } export function registerCalendarContentHandlers(): void { + if (!resumeChecked) { + resumeChecked = true; + void resumeInterruptedCalendarSync(); + } if (listenerRegistered) return; listenerRegistered = true;