From 9166bebef7b8ea2aa66e74de2a80d291f85b0f59 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Wed, 10 Jun 2026 01:17:13 +0930 Subject: [PATCH 1/3] all settings sync --- docs/CLOUD_SETTINGS_SYNC_OPTIONS.md | 1230 +++++++++++++++++ src/background.ts | 88 +- src/css/injected/popup.scss | 2 +- src/interface/pages/settings/shortcuts.svelte | 28 +- .../gradeAnalytics/AnalyticsAreaChart.svelte | 38 +- .../gradeAnalytics/AnalyticsBarChart.svelte | 40 +- .../built-in/gradeAnalytics/styles.css | 71 +- src/plugins/built-in/gradeAnalytics/ui.ts | 21 + src/seqta/utils/cloudSettingsSync.ts | 2 +- src/seqta/utils/defaultSettings.ts | 73 + .../utils/ensureSyncableStorageDefaults.ts | 116 ++ src/seqta/utils/listeners/SettingsState.ts | 132 +- src/seqta/utils/listeners/StorageChanges.ts | 12 +- 13 files changed, 1672 insertions(+), 181 deletions(-) create mode 100644 docs/CLOUD_SETTINGS_SYNC_OPTIONS.md create mode 100644 src/seqta/utils/defaultSettings.ts create mode 100644 src/seqta/utils/ensureSyncableStorageDefaults.ts diff --git a/docs/CLOUD_SETTINGS_SYNC_OPTIONS.md b/docs/CLOUD_SETTINGS_SYNC_OPTIONS.md new file mode 100644 index 00000000..34d88bbc --- /dev/null +++ b/docs/CLOUD_SETTINGS_SYNC_OPTIONS.md @@ -0,0 +1,1230 @@ +# Cloud settings sync — complete options & format reference + +Exhaustive reference for every value that **is** or **would be** included in a BetterSEQTA Cloud settings backup, the exact JSON shapes stored in `chrome.storage.local`, and how those shapes appear on the wire. + +**Related docs** + +- Server HTTP contract: [CLOUD_SETTINGS_SYNC_SERVER.md](./CLOUD_SETTINGS_SYNC_SERVER.md) +- Client upload/download: `src/seqta/utils/cloudSettingsSync.ts` +- Auto-sync (debounce, poll, triggers): `src/background/cloudSettingsAutoSync.ts` +- Core defaults: `src/seqta/utils/defaultSettings.ts` +- Full-schema initializer: `src/seqta/utils/ensureSyncableStorageDefaults.ts` +- In-memory settings + persistence: `src/seqta/utils/listeners/SettingsState.ts` +- Type definitions: `src/types/storage.ts` + +--- + +## Table of contents + +1. [Architecture](#architecture) +2. [Sync lifecycle](#sync-lifecycle) +3. [Wire format (HTTP body)](#wire-format-http-body) +4. [Local storage model](#local-storage-model) +5. [Exclusion rules](#exclusion-rules) +6. [Legacy key migration](#legacy-key-migration) +7. [Core extension settings](#core-extension-settings) +8. [Plugin settings objects](#plugin-settings-objects) +9. [Plugin runtime storage keys](#plugin-runtime-storage-keys) +10. [Excluded caches (local-only schemas)](#excluded-caches-local-only-schemas) +11. [Data outside `chrome.storage.local`](#data-outside-chromestoragelocal) +12. [Catch-all & forward compatibility](#catch-all--forward-compatibility) +13. [UI accessibility](#ui-accessibility) +14. [Default schema initialization](#default-schema-initialization) + +--- + +## Architecture + +Cloud settings sync is a **whole-snapshot backup** of extension local storage: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ chrome.storage.local (flat string-keyed JSON values) │ +│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ +│ │ Core keys │ │ plugin.* │ │ Excluded (see §5) │ │ +│ │ onoff, │ │ .settings │ │ OAuth, device caches, │ │ +│ │ DarkMode, │ │ .storage.* │ │ client watermarks │ │ +│ │ menuitems… │ │ │ │ │ │ +│ └─────────────┘ └──────────────┘ └────────────────────────┘ │ +└────────────────────────────┬────────────────────────────────────┘ + │ buildUploadPayload() + │ • filter exclusions + │ • migrateLegacyToPluginSettings() + ▼ + PUT /api/bsplus/settings/sync + { schemaVersion, themeId, data: { … } } + │ + ▼ + accounts.betterseqta.org (one row per user) +``` + +**Design principle:** inclusive by default. Any new key written to `chrome.storage.local` is uploaded unless explicitly added to an omit list in `cloudSettingsSync.ts`. + +**Not included:** IndexedDB, `localStorage`, `sessionStorage`, or SEQTA page DOM state. + +--- + +## Sync lifecycle + +### When upload runs + +| Trigger | Behaviour | +|---------|-----------| +| Any **included** `chrome.storage.local` key changes | Debounced upload after **2000 ms** (`UPLOAD_DEBOUNCE_MS`) | +| Manual upload (settings UI) | Immediate `PUT` | +| First poll with no cloud backup and no local watermark | Baseline upload | +| `requestCloudSettingsDebouncedUpload()` | Same debounced path (e.g. after theme install) | + +Upload is skipped when: + +- `autoCloudSettingsSync === false` +- No `bsplus_token` (not signed in) +- `suppressAutoUploadDuringRestore` is true (during download) + +### When download runs + +| Trigger | Behaviour | +|---------|-----------| +| Manual restore (settings UI) | `GET` → `applyDownloadedEnvelope` → theme prefetch → reload SEQTA tabs | +| Auto poll | If `bsplus.updated_at` from `GET /api/user/cloud-summary` is newer than local watermark | +| First poll with cloud backup but no watermark | Full download | + +Download is skipped when server `schemaVersion` > client `CLOUD_SETTINGS_SYNC_SCHEMA_VERSION` (currently `1`). + +### Poll throttle + +- Key: `bsplus_lastCloudPoll` — **never uploaded** +- Value: `number` (Unix ms timestamp) +- Minimum interval between poll runs: **24 hours** (`POLL_THROTTLE_MS`) + +### Restore semantics + +`applyDownloadedEnvelope` calls `browser.storage.local.set(remoteSanitized)` with **only** keys from the server (after migration + strip). It does **not** wipe storage: + +- **OAuth keys** remain because they are stripped from the remote blob and never overwritten. +- **Excluded device caches** remain for the same reason. +- **Client-only keys** (`bsplus_cloud_settings_known_remote_updated_at`, etc.) remain unless accidentally present in an old server payload (stripped defensively). +- Keys present locally but **absent** from an older cloud snapshot are **not deleted**. + +After download, if `themeId` / `selectedTheme` is non-empty, the service worker sets `bsplus_pending_theme_ensure_after_cloud` so the page `ThemeManager` can download missing theme assets from the store. + +### Full schema before upload + +`ensureSyncableStorageDefaults()` (`src/seqta/utils/ensureSyncableStorageDefaults.ts`) ensures every **cloud-syncable** key exists in `chrome.storage.local` with its default value if it was previously absent. This makes uploads and dev JSON exports contain a complete schema (e.g. `customshortcuts: []`, `shortcuts: [...]`, every `plugin.{id}.settings` object) instead of omitting keys the user never touched. + +| When it runs | Context | +|--------------|---------| +| `browser.runtime.onInstalled` | Service worker (install + update) | +| `browser.runtime.onStartup` | Service worker | +| Service worker load | `background.ts` (once at startup) | +| `initializeSettingsState()` | SEQTA content script + extension settings page (first init) | + +Rules: + +- Builds defaults from `getDefaultSettingsState()` plus each plugin’s `plugin.{id}.settings` defaults from `getAllPluginSettings()`. +- **Does not backfill legacy keys** (`animatedbk`, `bksliderinput`, etc.) — missing `plugin.*.settings` are derived from legacy via `migrateLegacyToPluginSettings(existing)` when patched. +- **Does not backfill optional keys** where `undefined` is intentional (`timeFormat`, `selectedFont`, dev/privacy/announcement flags, etc.) so existing behaviour is unchanged. +- Skips keys in the cloud omit lists (`isKeyIncludedInCloudUploadPayload`). +- **Never overwrites** existing storage values — only patches keys absent from storage (`key in existing` is false). +- Settings writes during bootstrap do not fire UI listeners (`SettingsState.bootstrapping`); user edits persist **only the changed key**, not the whole in-memory object. + +--- + +## Wire format (HTTP body) + +### `PUT /api/bsplus/settings/sync` request + +```http +PUT /api/bsplus/settings/sync HTTP/1.1 +Host: accounts.betterseqta.org +Authorization: Bearer +Content-Type: application/json +``` + +```json +{ + "schemaVersion": 1, + "themeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "data": { + "onoff": true, + "DarkMode": true, + "selectedTheme": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "selectedColor": "linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)", + "plugin.global-search.settings": { + "enabled": true, + "searchHotkey": "ctrl+k", + "showRecentFirst": true, + "transparencyEffects": true, + "runIndexingOnLoad": true, + "passiveIndexing": true + } + } +} +``` + +| Top-level field | JSON type | Rules | +|-----------------|-----------|-------| +| `schemaVersion` | `number` | Always `1` today (`CLOUD_SETTINGS_SYNC_SCHEMA_VERSION`) | +| `themeId` | `string` | `normalizeThemeIdForSync(selectedTheme)` — trimmed; `""` if unset/invalid | +| `data` | `object` | Flat map: storage key → value. Same types as `chrome.storage.local`. No nesting convention beyond whatever each key stores. | + +### `GET /api/bsplus/settings/sync` response + +Same shape as the PUT body, plus optional: + +```json +{ + "schemaVersion": 1, + "themeId": "…", + "data": { }, + "updated_at": "2026-04-07T12:00:00.000Z" +} +``` + +- `updated_at`: ISO 8601 UTC string; written to `bsplus_cloud_settings_known_remote_updated_at` locally (never re-uploaded). + +### JSON encoding notes + +- All values must be JSON-serializable (`boolean`, `number`, `string`, `null`, arrays, plain objects). +- `undefined` is never stored by the WebExtension storage API. +- Dates are stored as **strings** (ISO or calendar formats), not `Date` objects. +- `chrome.storage.local` may historically stringify some booleans in edge cases; the client treats them as booleans after read. + +--- + +## Local storage model + +### Flat key namespace + +Every persisted setting is a **top-level key** in `chrome.storage.local`: + +| Pattern | Example | Value shape | +|---------|---------|---------------| +| Core setting | `DarkMode` | scalar or structured JSON | +| Plugin settings | `plugin.global-search.settings` | single JSON **object** with all plugin prefs | +| Plugin storage | `plugin.messageFolders.storage.folders` | one JSON value per property | +| Analytics cache | `bsplus.analytics.v2.https://school.seqta.com.au.12345` | structured cache (excluded) | + +Plugin settings use `plugin.{pluginId}.settings` where `pluginId` matches the plugin registration id (e.g. `messageFolders`, not `message-folders`). + +Plugin runtime storage uses `plugin.{pluginId}.storage.{propertyName}` — each property is a **separate** storage key, not nested under one object. + +### Settings vs storage + +| Mechanism | API | Persisted keys | Synced? | +|-----------|-----|----------------|---------| +| `settingsState` / `SettingsState` | Proxy over in-memory + `storage.local` | Top-level keys (`onoff`, `menuitems`, …) | Yes (unless excluded) | +| Plugin `api.settings` | Proxy; one object per plugin | `plugin.{id}.settings` | Yes | +| Plugin `api.storage` | Proxy; one key per property | `plugin.{id}.storage.{prop}` | Yes, except excluded prefixes | + +Component/button plugin settings (`type: "component"` | `"button"`) are **not** written by the settings proxy loop; only scalar settings defined in `plugin.settings` are persisted automatically. The settings UI may still write `enabled` and other keys via `browser.storage.local.set` directly. + +--- + +## Exclusion rules + +Implemented in `shouldOmitKeyFromCloudPayload(key)`: + +### Exact key exclusions + +| Key | Format if present locally | On restore | +|-----|---------------------------|------------| +| `bsplus_token` | `string` JWT | Keep device value | +| `bsplus_refresh_token` | `string` | Keep device value | +| `bsplus_client_id` | `string` UUID | Keep device value | +| `bsplus_user` | `CloudUser` object (see below) | Keep device value | +| `cloudAccessToken` | `string` (legacy) | Keep device value | +| `cloudUsername` | `string` (legacy) | Keep device value | +| `plugin.assessments-average.storage.assessments` | object | Keep device value | +| `plugin.assessments-average.storage.weightings` | object | Keep device value | +| `bsplus_cloud_settings_known_remote_updated_at` | ISO `string` | Keep device value | +| `bsplus_lastCloudPoll` | `number` (ms) | Keep device value | +| `bsplus_pending_theme_ensure_after_cloud` | `string` (theme id) | Keep device value | + +#### `bsplus_user` shape (local only, never uploaded) + +```json +{ + "id": "uuid", + "email": "user@example.com", + "username": "optional", + "displayName": "optional", + "pfpUrl": "https://…", + "pfpHash": "abc123" , + "admin_level": 0 +} +``` + +All fields except `id` are optional. + +### Prefix exclusions + +| Prefix | Matches | +|--------|---------| +| `plugin.global-search.storage.` | Any Global Search device cache key | +| `bsplus.analytics.` | Grade Analytics caches and chart mode prefs | + +Prefix check: `key.startsWith(prefix)`. + +--- + +## Legacy key migration + +Runs in `migrateLegacyToPluginSettings()` on **both** upload and download. Legacy keys are **removed** from `data` after migration. Migration only fills plugin settings fields that are still `undefined`. + +| Legacy key | Legacy format | Target key | Target field | Conversion | +|------------|---------------|------------|--------------|------------| +| `animatedbk` | `boolean` | `plugin.animated-background.settings` | `enabled` | `!!animatedbk` | +| `bksliderinput` | `string` `"0"`–`"100"` | `plugin.animated-background.settings` | `speed` | `speed = round((0.1 + (parseFloat(s) / 100) * 1.9) * 100) / 100` → range **0.1–2.0** | +| `assessmentsAverage` | `boolean` | `plugin.assessments-average.settings` | `enabled` | `!!assessmentsAverage` | +| `lettergrade` | `boolean` | `plugin.assessments-average.settings` | `lettergrade` | `!!lettergrade` | +| `notificationCollector` | `boolean` only | `plugin.notificationCollector.settings` | `enabled` | copy boolean | + +**Example:** legacy `bksliderinput: "50"` → `speed: 1.05` because `0.1 + 0.5 * 1.9 = 1.05`. + +Modern clients should only see plugin-format keys in uploaded payloads; legacy keys may still exist on very old profiles until first sync. + +--- + +## Core extension settings + +Top-level `chrome.storage.local` keys from `SettingsState` (`src/types/storage.ts`). Defaults from `getDefaultSettingsState()` in `src/seqta/utils/defaultSettings.ts` unless noted. Missing keys are backfilled by `ensureSyncableStorageDefaults()` (see [Default schema initialization](#default-schema-initialization)). + +### Master & appearance + +#### `onoff` + +| | | +|-|-| +| **Type** | `boolean` | +| **Default** | `true` | +| **UI** | Settings → “BetterSEQTA+” master switch | +| **Example** | `true` | + +#### `DarkMode` + +| | | +|-|-| +| **Type** | `boolean` | +| **Default** | `true` | +| **UI** | SEQTA UI / theme system | +| **Example** | `true` | + +#### `selectedTheme` + +| | | +|-|-| +| **Type** | `string` | +| **Default** | `""` | +| **UI** | Theme selector / store install | +| **Format** | BetterSEQTA store theme UUID, **flavour (slave) variant** id, or empty string for no store theme | +| **Wire** | Duplicated as top-level `themeId` on upload (trimmed) | +| **Example** | `"f47ac10b-58cc-4372-a567-0e02b2c3d479"` or `""` | + +Theme **asset blobs** (CSS, images) live in **localforage/IndexedDB**, not in this key. After restore, only the id is synced; assets are re-fetched if missing. + +#### `selectedColor` + +| | | +|-|-| +| **Type** | `string` | +| **Default** | `"linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)"` | +| **UI** | Colour picker | +| **Format** | Any valid CSS `background` value: hex (`#c93d00`), `rgb()`, `rgba()`, `linear-gradient(...)`, etc. | +| **Example** | `"#c93d00"` or `"linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)"` | + +#### `originalSelectedColor` + +| | | +|-|-| +| **Type** | `string` | +| **Default** | `""` | +| **Purpose** | Colour before theme preview; restored when clearing theme | +| **Example** | `"#c93d00"` | + +#### `originalDarkMode` + +| | | +|-|-| +| **Type** | `boolean` | +| **Default** | undefined until theme preview | +| **Purpose** | Dark mode before theme preview | +| **Example** | `true` | + +#### `transparencyEffects` + +| | | +|-|-| +| **Type** | `boolean` | +| **Default** | `false` | +| **UI** | Settings → Transparency Effects | +| **Example** | `false` | + +#### `animations` + +| | | +|-|-| +| **Type** | `boolean` | +| **Default** | `true` on normal devices; `false` if low-end (`hardwareConcurrency < 4` or `deviceMemory <= 2`) | +| **UI** | Settings → Animations | +| **Example** | `true` | + +#### `iconOnlySidebar` + +| | | +|-|-| +| **Type** | `boolean` | +| **Default** | `false` | +| **UI** | Settings → Icon Only Sidebar | +| **Example** | `false` | + +#### `selectedFont` + +| | | +|-|-| +| **Type** | `string` | +| **Default** | undefined → runtime treats as `"rubik"` | +| **UI** | Settings → Interface Font | +| **Format** | Font preset id from `src/seqta/ui/fonts/presets.ts` | + +Allowed ids: + +`rubik`, `inter`, `poppins`, `nunito`, `montserrat`, `open-sans`, `lato`, `source-sans-3`, `raleway`, `dm-sans`, `plus-jakarta-sans`, `outfit`, `roboto`, `work-sans`, `manrope`, `figtree`, `lexend`, `ubuntu`, `karla`, `quicksand`, `ibm-plex-sans`, `space-grotesk`, `mulish`, `cabin`, `oswald`, `merriweather`, `playfair-display`, `lora`, `crimson-pro`, `libre-baskerville`, `system` + +**Example:** `"inter"` + +#### `adaptiveThemeColour` + +| | | +|-|-| +| **Type** | `boolean` | +| **Default** | `false` | +| **Example** | `false` | + +#### `adaptiveThemeGradient` + +| | | +|-|-| +| **Type** | `boolean` | +| **Default** | `false` | +| **Example** | `false` | + +#### `adaptiveThemeColourTransition` + +| | | +|-|-| +| **Type** | `boolean` | +| **Default** | `true` | +| **Example** | `true` | + +### Navigation & layout + +#### `defaultPage` + +| | | +|-|-| +| **Type** | `string` | +| **Default** | `"home"` | +| **UI** | Settings → Default Page | +| **Allowed** | `"home"`, `"dashboard"`, `"timetable"`, `"welcome"`, `"messages"`, `"documents"`, `"reports"` | +| **Example** | `"home"` | + +#### `timeFormat` + +| | | +|-|-| +| **Type** | `string` | +| **Default** | undefined (24-hour behaviour) | +| **UI** | Settings → “12 Hour Time” switch | +| **Values** | `"12"` when enabled; `"24"` when disabled | +| **Example** | `"12"` | + +#### `lessonalert` + +| | | +|-|-| +| **Type** | `boolean` | +| **Default** | `true` | +| **Purpose** | Lesson alert on home page | +| **Example** | `true` | + +#### `menuitems` + +| | | +|-|-| +| **Type** | `object` | +| **Default** | All sidebar items `{ toggle: true }` | +| **UI** | Menu editor overlay | +| **Shape** | `{ [menuKey]: { toggle: boolean } }` | + +Keys (from `SettingsState`): + +`assessments`, `courses`, `dashboard`, `documents`, `forums`, `goals`, `home`, `messages`, `myed`, `news`, `notices`, `portals`, `reports`, `settings`, `timetable`, `welcome` + +**Example:** + +```json +{ + "home": { "toggle": true }, + "timetable": { "toggle": false }, + "messages": { "toggle": true } +} +``` + +#### `menuorder` + +| | | +|-|-| +| **Type** | `array` | +| **Default** | `[]` | +| **UI** | Menu editor (drag order) | +| **Item type** | `string` — SEQTA menu `data-key` values (e.g. `"home"`, `"timetable"`, `"analytics"` for plugin-injected items) | +| **Example** | `["home", "timetable", "assessments", "messages"]` | + +#### `defaultmenuorder` + +| | | +|-|-| +| **Type** | `array` | +| **Default** | `[]` (populated from DOM on first menu edit) | +| **Item type** | Same as `menuorder` — snapshot of school default order | +| **Example** | `["home", "dashboard", "timetable"]` | + +### Shortcuts + +#### `shortcuts` + +| | | +|-|-| +| **Type** | `array` | +| **Default** | Outlook, Office, Google — each `enabled: true` | +| **UI** | Settings → Shortcuts | +| **Item shape** | `{ "name": string, "enabled": boolean }` | +| **Notes** | `name` matches keys in `src/seqta/content/links.json` (e.g. `"Outlook"`, `"YouTube"`) | + +**Example:** + +```json +[ + { "name": "Outlook", "enabled": true }, + { "name": "Office", "enabled": false }, + { "name": "Google", "enabled": true } +] +``` + +#### `customshortcuts` + +| | | +|-|-| +| **Type** | `array` | +| **Default** | `[]` | +| **UI** | Settings → Shortcuts → add custom | +| **Item shape** | `{ "name": string, "url": string, "icon": string }` | +| **`url`** | Full URL with protocol, e.g. `"https://example.com"` | +| **`icon`** | Inline **SVG markup string** OR single fallback character (first letter of title) | + +**Example:** + +```json +[ + { + "name": "School Portal", + "url": "https://portal.school.edu.au", + "icon": "" + } +] +``` + +### Subjects & news + +#### `subjectfilters` + +| | | +|-|-| +| **Type** | `object` | +| **Default** | `{}` | +| **UI** | Home / assessments subject filters | +| **Shape** | `{ [subjectCode: string]: boolean }` — `false` hides subject; missing key = visible | +| **Example** | `{ "10MAT": false, "10ENG": true }` | + +#### `newsSource` + +| | | +|-|-| +| **Type** | `string` | +| **Default** | `"australia"` | +| **UI** | Settings → News Feed Source | +| **Allowed** | `australia`, `usa`, `uk`, `taiwan`, `hong_kong`, `panama`, `canada`, `singapore`, `japan`, `netherlands` | +| **Example** | `"australia"` | + +### Cloud sync preference + +#### `autoCloudSettingsSync` + +| | | +|-|-| +| **Type** | `boolean` | +| **Default** | `true` | +| **UI** | Settings → Cloud Settings Sync | +| **Semantics** | Sync runs when **not** strictly `false` (default-on) | +| **Example** | `true` | + +### Theme of the Month + +#### `themeOfTheMonthDisabled` + +| | | +|-|-| +| **Type** | `boolean` | +| **Default** | `false` | +| **UI** | Global Search plugin section → Theme of the Month switch (inverted in UI) | +| **Example** | `false` | + +#### `themeOfTheMonthDismissedMonth` + +| | | +|-|-| +| **Type** | `string` | +| **Format** | `"YYYY-MM"` calendar month | +| **Example** | `"2026-06"` | + +#### `themeOfTheMonthLastSeenId` + +| | | +|-|-| +| **Type** | `string` | +| **Status** | Deprecated; may still exist in old profiles | +| **Synced** | Yes if present | + +### One-time announcements & privacy + +#### `privacyStatementShown` + +| | | +|-|-| +| **Type** | `boolean` | +| **Example** | `true` | + +#### `privacyStatementLastUpdated` + +| | | +|-|-| +| **Type** | `string` | +| **Format** | ISO date, e.g. `"2025-12-20"` | + +#### `engageParentsAnnouncementShown` + +| | | +|-|-| +| **Type** | `boolean` | +| **Purpose** | SEQTA Engage parents announcement dismissed | + +#### `bsCloudAutoSyncAnnouncementShown` + +| | | +|-|-| +| **Type** | `boolean` | +| **Purpose** | Cloud auto-sync announcement dismissed | + +### Update & developer flags + +#### `justupdated` + +| | | +|-|-| +| **Type** | `boolean` | +| **Purpose** | Set `true` after extension update; drives startup popup queue | +| **Example** | `true` | + +#### `devMode` + +| | | +|-|-| +| **Type** | `boolean` | +| **Default** | undefined / false | +| **UI** | Hidden unlock in settings | +| **Example** | `true` | + +#### `hideSensitiveContent` + +| | | +|-|-| +| **Type** | `boolean` | +| **UI** | Dev mode → Sensitive Hider | +| **Example** | `false` | + +#### `mockNotices` + +| | | +|-|-| +| **Type** | `boolean` | +| **UI** | Dev mode → Mock Notices | +| **Example** | `false` | + +#### `devGhReleaseVersionOverride` + +| | | +|-|-| +| **Type** | `string` | +| **UI** | Dev mode → version override field | +| **Format** | Semver string, e.g. `"3.7.0"` | +| **Example** | `"99.0.0"` | + +#### `lastSeenNightlyPublishedAt` + +| | | +|-|-| +| **Type** | `string` | +| **Format** | ISO 8601 timestamp from GitHub release `published_at` | +| **Example** | `"2026-06-01T04:30:00Z"` | + +### Profile picture helper + +#### `profile_picture_revision` + +| | | +|-|-| +| **Type** | `number` | +| **Purpose** | Incremented when cloud/local profile picture changes; triggers UI refresh | +| **Not** | The image itself (stored in localforage `profile-picture-store`) | +| **Example** | `3` | + +--- + +## Plugin settings objects + +Each plugin stores one object at `plugin.{pluginId}.settings`. Plugins with `disableToggle: true` also store `enabled: boolean` (written from settings UI, not the settings proxy). + +Settings of type `component` or `button` are **not** auto-persisted by the plugin settings proxy. + +### `plugin.animated-background.settings` + +| Field | Type | Default | Range / notes | +|-------|------|---------|---------------| +| `enabled` | `boolean` | `true` | From `disableToggle` UI | +| `speed` | `number` | `1` | **0.1–2.0**, step 0.05 in UI | + +```json +{ "enabled": true, "speed": 1.05 } +``` + +### `plugin.assessments-average.settings` + +| Field | Type | Default | +|-------|------|---------| +| `enabled` | `boolean` | `false` | +| `lettergrade` | `boolean` | `false` | + +```json +{ "enabled": true, "lettergrade": false } +``` + +### `plugin.notificationCollector.settings` + +| Field | Type | Default | +|-------|------|---------| +| `enabled` | `boolean` | `true` | + +```json +{ "enabled": true } +``` + +### `plugin.timetable.settings` + +| Field | Type | Default | +|-------|------|---------| +| `enabled` | `boolean` | `true` | + +```json +{ "enabled": true } +``` + +### `plugin.timetableEdit.settings` + +| Field | Type | Default | +|-------|------|---------| +| `enabled` | `boolean` | `true` | + +```json +{ "enabled": true } +``` + +### `plugin.global-search.settings` + +| Field | Type | Default | Notes | +|-------|------|---------|-------| +| `enabled` | `boolean` | `false` | Plugin default off | +| `searchHotkey` | `string` | `"ctrl+k"` or `"cmd+k"` | See hotkey format below | +| `showRecentFirst` | `boolean` | `true` | | +| `transparencyEffects` | `boolean` | `true` | Search bar blur | +| `runIndexingOnLoad` | `boolean` | `true` | | +| `passiveIndexing` | `boolean` | `true` | Index visited pages | + +```json +{ + "enabled": true, + "searchHotkey": "ctrl+shift+f", + "showRecentFirst": true, + "transparencyEffects": true, + "runIndexingOnLoad": true, + "passiveIndexing": true +} +``` + +#### Hotkey string format (`searchHotkey`) + +- Lowercase, `+`-separated: `modifier[+modifier]+key` +- Modifiers: `ctrl`/`control`, `cmd`/`meta`/`command`, `alt`/`option`, `shift` +- Key: single character or name (`k`, `f`, etc.) — matched against `event.key` case-insensitively +- Valid iff at least one non-modifier key is present (`isValidHotkey`) +- **Examples:** `"ctrl+k"`, `"cmd+shift+p"`, `"alt+f"` + +### `plugin.profile-picture.settings` + +| Field | Type | Default | +|-------|------|---------| +| `enabled` | `boolean` | `false` | +| `useCloudPfp` | `boolean` | `false` | + +```json +{ "enabled": true, "useCloudPfp": true } +``` + +When `useCloudPfp` is true, avatar URL comes from `bsplus_user.pfpUrl` (local auth, not synced) — other devices need their own login. + +### `plugin.messageFolders.settings` + +| Field | Type | Default | +|-------|------|---------| +| `enabled` | `boolean` | `true` | +| `showTagsInAllMessages` | `boolean` | `true` | +| `hideFolderedMessagesInAll` | `boolean` | `true` | + +```json +{ + "enabled": true, + "showTagsInAllMessages": true, + "hideFolderedMessagesInAll": true +} +``` + +### `plugin.enhanced-navigation.settings` + +| Field | Type | Default | +|-------|------|---------| +| `enabled` | `boolean` | `true` | +| `autoScrollOnClick` | `boolean` | `false` | + +```json +{ "enabled": true, "autoScrollOnClick": false } +``` + +### `plugin.background-music.settings` + +| Field | Type | Default | Range | +|-------|------|---------|-------| +| `enabled` | `boolean` | `false` | | +| `volume` | `number` | `0.5` | 0–1 | +| `pauseOnHidden` | `boolean` | `true` | | + +```json +{ "enabled": true, "volume": 0.75, "pauseOnHidden": true } +``` + +Audio blob: localforage `background-music-store` / `music` / key `audio-blob` — **not synced**. + +### `plugin.grade-analytics.settings` + +| Field | Type | Default | Range | +|-------|------|---------|-------| +| `cacheTtlHours` | `number` | `24` | 1–168 | + +```json +{ "cacheTtlHours": 48 } +``` + +No `enabled` field — plugin uses `disableToggle: false` and always runs when registered (except Engage). + +### Unused plugin settings keys + +- `plugin.themes.settings` — not written (empty plugin settings) +- `plugin.assessments-overview.settings` — not written + +--- + +## Plugin runtime storage keys + +Separate top-level keys per property: `plugin.{pluginId}.storage.{propertyName}`. + +### Message Folders (`plugin.messageFolders`) + +#### `plugin.messageFolders.storage.folders` + +```json +[ + { + "id": "m1abc2def", + "name": "Important", + "color": "#3b82f6", + "emoji": "" + } +] +``` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | `string` | `Date.now().toString(36) + random` | +| `name` | `string` | Display name | +| `color` | `string` | Hex colour from preset palette | +| `emoji` | `string` | Inline SVG icon markup (not unicode emoji) | + +#### `plugin.messageFolders.storage.messageAssignments` + +```json +{ + "msg-uuid-1": ["folderId1"], + "msg-uuid-2": ["folderId1", "folderId2"] +} +``` + +Keys: SEQTA message identifiers. Values: arrays of folder `id` strings. + +### Timetable Edit (`plugin.timetableEdit`) + +#### `plugin.timetableEdit.storage.timetableOverrides` + +Keyed by class instance id (`ci` as string): + +```json +{ + "42": { "room": "B204", "staff": "Mr Smith" }, + "17": { "staff": "Ms Jones" } +} +``` + +#### `plugin.timetableEdit.storage.timetableOverridesBySubject` + +Keyed by subject description string: + +```json +{ + "10 Mathematics": { "room": "MA1" } +} +``` + +Entry shape: `{ "room"?: string, "staff"?: string }` — either field optional. + +### Assessment Averages (`plugin.assessments-average`) + +#### `plugin.assessments-average.storage.weightingOverrides` — **synced** + +User manual weight overrides by assessment id: + +```json +{ + "123456": "25", + "123457": "N/A" +} +``` + +Values are weight strings as shown in UI (percentage text or `"N/A"`). + +#### `plugin.assessments-average.storage.assessments` — **NOT synced** + +Title → assessment id map (school data): + +```json +{ + "Semester 1 Exam": "123456" +} +``` + +#### `plugin.assessments-average.storage.weightings` — **NOT synced** + +```json +{ + "123456": { + "weight": "25", + "fingerprint": "[\"GRADED\",true,\"…\"]", + "pluginVersion": 1, + "refreshing": false + } +} +``` + +| Field | Type | Notes | +|-------|------|-------| +| `weight` | `string` | e.g. `"25"`, `"processing"`, `"N/A"` | +| `fingerprint` | `string` | JSON-stringified assessment state fingerprint | +| `pluginVersion` | `number` | `WEIGHTING_SCHEMA_VERSION` (= 1) | +| `refreshing` | `boolean` | optional; background refetch in progress | + +### Notification Collector (`plugin.notificationCollector`) + +| Key | Type | Example | +|-----|------|---------| +| `plugin.notificationCollector.storage.lastNotificationCount` | `number` | `3` | +| `plugin.notificationCollector.storage.consecutiveErrors` | `number` | `0` | +| `plugin.notificationCollector.storage.lastCheckedTime` | `string` | `"2026-06-09T14:30:00.000Z"` | + +All **synced** (not under an excluded prefix). + +--- + +## Excluded caches (local-only schemas) + +### Grade Analytics — prefix `bsplus.analytics.` + +#### `bsplus.analytics.v2.{origin}.{studentId}` + +- `origin`: full school origin, e.g. `https://school.seqta.com.au` (dots in hostname appear in key) +- `studentId`: numeric SEQTA student id + +```json +{ + "updatedAt": 1717939200000, + "assessments": [ + { + "id": 1, + "title": "Assignment 1", + "subject": "10 Mathematics", + "status": "MARKS_RELEASED", + "due": "2026-03-15", + "code": "10MAT", + "metaclassID": 100, + "programmeID": 10, + "graded": true, + "overdue": false, + "hasFeedback": true, + "expectationsEnabled": false, + "expectationsCompleted": false, + "reflectionsEnabled": false, + "reflectionsCompleted": false, + "availability": "FULL", + "finalGrade": 85, + "letterGrade": "A" + } + ] +} +``` + +`status` enum: `"OVERDUE"` | `"MARKS_RELEASED"` | `"PENDING"`. + +#### `bsplus.analytics.distMode.v1.{origin}.{studentId}` + +Scalar string: `"auto"` | `"letter"` | `"percent"`. + +### Global Search — prefix `plugin.global-search.storage.` + +Reserved for future device-local caches. No keys in use today; any key matching the prefix is excluded. + +--- + +## Data outside `chrome.storage.local` + +These affect UX but **never** appear in the cloud `data` blob: + +| Store | Location | Contents | +|-------|----------|----------| +| Global Search structured index | IndexedDB `betterseqta-index` | Searchable page text | +| Global Search vectors | IndexedDB `embeddiaDB` | Embedding index | +| Global Search schema version | `localStorage` key `betterseqta-index-version` | Index migration | +| Installed themes | localforage (default instance) | `CustomTheme` objects keyed by theme id; index `customThemes` | +| Profile picture upload | localforage `profile-picture-store` / `profilePicture` | Image blob | +| Cloud PFP cache | localforage `cloud-pfp-store` / `cloudPfp` | Cached avatar blobs | +| Background music | localforage `background-music-store` / `music` / `audio-blob` | Audio blob | +| Dev API override | `sessionStorage` `bsplus_dev_api_base` | Staging server URL | +| Engage student context | `localStorage` `bsplus.engageTimetable.student.{origin}` | Current student id | + +After cloud restore, devices may need to **rebuild** indexes, **re-upload** media, or **re-download** themes even when synced settings reference them. + +--- + +## Catch-all & forward compatibility + +### Inclusion test + +```ts +isKeyIncludedInCloudUploadPayload(key) === !shouldOmitKeyFromCloudPayload(key) +``` + +Any new `chrome.storage.local` key syncs automatically unless added to: + +- `KEYS_OMITTED_FROM_CLOUD_UPLOAD` +- `SENSITIVE_DEVICE_STORAGE_KEYS_EXACT` +- `CLIENT_ONLY_CLOUD_KEYS_EXACT` +- `SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES` + +### Upload trigger flow + +1. `browser.storage.onChanged` (area `local`) +2. At least one changed key passes `isKeyIncludedInCloudUploadPayload` +3. `autoCloudSettingsSync !== false` +4. Valid access token +5. Not during restore → schedule 2 s debounce → `PUT` full snapshot + +### Server storage suggestion + +Store the full PUT body (or at minimum `{ schemaVersion, themeId, data }`) as JSONB per user. `data` alone is sufficient for restore if `themeId` is duplicated inside `data.selectedTheme`. + +### Versioning + +- Client `schemaVersion`: **1** +- If server returns higher `schemaVersion` in cloud-summary, auto-download is skipped until client is updated. + +--- + +## UI accessibility + +Which synced keys can be changed directly by the user in the extension **settings popup** (SEQTA → BetterSEQTA+ settings) or other in-product UI — vs keys that are written only by the app, popups, or plugins at runtime. + +**Settings popup tabs:** Settings · Shortcuts · Themes (`src/interface/pages/settings.svelte`). + +### Extension popup — Settings tab (`general.svelte`) + +| Storage key / path | UI label | Control | +|--------------------|----------|---------| +| `iconOnlySidebar` | Icon Only Sidebar | Switch | +| `animations` | Animations | Switch | +| `timeFormat` | 12 Hour Time | Switch (`"12"` / `"24"`) | +| `transparencyEffects` | Transparency Effects | Switch | +| `defaultPage` | Default Page | Select | +| `newsSource` | News Feed Source | Select | +| `selectedColor` | Custom Theme Colour | Button → colour picker modal | +| `selectedFont` | Interface Font | Button → font picker modal | +| `adaptiveThemeColour` | Adaptive Theme Colour | Switch | +| `adaptiveThemeGradient` | Soft Gradient | Switch (when adaptive colour on) | +| `adaptiveThemeColourTransition` | Smooth colour transition | Switch (when adaptive colour on) | +| `menuorder`, `menuitems`, `defaultmenuorder` | Edit Sidebar Layout | Button → opens editor **on the SEQTA tab** (not inside the popup) | +| `themeOfTheMonthDisabled` | Theme of the Month | Switch (under Global Search plugin block) | +| `autoCloudSettingsSync` | Automatic sync | Switch (BetterSEQTA Cloud card, signed in only) | +| `onoff` | BetterSEQTA+ | Switch (bottom of tab) | + +#### Plugin blocks on Settings tab + +Each row is `plugin.{id}.settings.{field}` unless noted. Component settings (upload UIs) change synced **preference** keys but not blob data in IndexedDB. + +| Plugin | UI | Synced fields from UI | +|--------|-----|------------------------| +| **Animated Background** | Enable + Animation Speed slider | `enabled`, `speed` | +| **Assessment Averages** | Enable (+ disclaimer) + Letter Grades | `enabled`, `lettergrade` | +| **Notification Collector** | Enable only | `enabled` | +| **Timetable Enhancer** | Enable only | `enabled` | +| **Edit Rooms & Teachers** | Enable only | `enabled` | +| **Global Search** | Enable + hotkey + 4 toggles + Reset Index button | `enabled`, `searchHotkey`, `showRecentFirst`, `transparencyEffects`, `runIndexingOnLoad`, `passiveIndexing` — Reset Index does **not** sync index data | +| **Custom Profile Picture** | Enable + Use cloud PFP (if signed in) + upload/remove | `enabled`, `useCloudPfp`; upload updates `profile_picture_revision` (image blob **not** synced) | +| **Message Folders** | Enable + 2 toggles | `enabled`, `showTagsInAllMessages`, `hideFolderedMessagesInAll` | +| **Enhanced Navigation** | Enable + Auto-scroll | `enabled`, `autoScrollOnClick` | +| **Background Music** | Enable + volume + pause on hidden + upload | `enabled`, `volume`, `pauseOnHidden`; audio blob **not** synced | +| **Grade Analytics** | Cache duration (hours) slider only | `cacheTtlHours` | + +**Not shown on Settings tab** (empty `settings` + no enable toggle → card hidden): + +- **Themes** plugin — use **Themes** tab instead +- **Assessments Overview** plugin — no settings UI + +#### Dev mode only (Settings tab, after typing “dev” on logo) + +| Storage key | UI label | +|-------------|----------| +| `devMode` | Developer Mode | +| `hideSensitiveContent` | Sensitive Hider | +| `mockNotices` | Mock Notices | +| `privacyStatementShown`, `privacyStatementLastUpdated` | Show Privacy Notification (resets to show popup) | +| `devGhReleaseVersionOverride` | GitHub latest version override (text field) | + +Dev **Export cloud settings JSON** downloads the upload payload; it does not change storage. + +### Extension popup — Shortcuts tab (`shortcuts.svelte`) + +| Storage key | UI | +|-------------|-----| +| `shortcuts` | Toggle built-in shortcuts (Outlook, Office, Google, … from `links.json`) | +| `customshortcuts` | Add / delete custom shortcuts (name, URL, optional SVG icon) | + +Changes use `settingsState.setKey()` so the SEQTA home page updates immediately via `StorageChangeHandler` → `renderShortcuts()` (embedded settings run in the content script, where `storage.onChanged` does not fire for local writes). Empty arrays are persisted explicitly (`customshortcuts: []`) so cloud restore and other devices clear removed shortcuts. + +### Extension popup — Themes tab (`theme.svelte`) + +| Storage key | UI | +|-------------|-----| +| `selectedTheme` | Install / select / clear store themes (`ThemeSelector`) | +| `selectedColor` | May change when applying a theme with a default colour | +| `originalSelectedColor`, `originalDarkMode` | Set internally during theme **preview** in theme manager (not dedicated controls) | + +Animated background selection uses `BackgroundSelector` (local/custom backgrounds); store theme id still goes to `selectedTheme`. + +### SEQTA page UI (outside settings popup) + +| Storage key / path | Where | Control | +|--------------------|-------|---------| +| `DarkMode` | SEQTA top bar | Sun/moon **Light/Dark** button | +| `menuorder`, `menuitems` | SEQTA sidebar | **Edit Sidebar Layout** overlay (drag order + per-item toggles) | +| `subjectfilters` | Home → upcoming assessments | Per-subject checkboxes (`#upcoming-filters`) | +| `subjectfilters` | Assessments → Overview | Subject filter UI in overview grid | +| `plugin.messageFolders.storage.folders` | Messages page | Create/edit/delete folders | +| `plugin.messageFolders.storage.messageAssignments` | Messages page | Assign messages to folders | +| `plugin.timetableEdit.storage.timetableOverrides` | Timetable | Edit room/teacher on a class | +| `plugin.timetableEdit.storage.timetableOverridesBySubject` | Timetable | Subject-level overrides | +| `plugin.assessments-average.storage.weightingOverrides` | Assessments page | Per-assessment “Override %” inputs | +| Global Search | SEQTA | Hotkey opens search bar (hotkey set in Settings) | + +### Automatic / popup-only (synced, no dedicated settings control) + +| Storage key / path | How it changes | +|--------------------|----------------| +| `lessonalert` | Default only — **no UI** (read by home loader) | +| `privacyStatementShown`, `privacyStatementLastUpdated` | Privacy popup on first run (dev can reset) | +| `engageParentsAnnouncementShown` | Dismiss Engage parents announcement | +| `bsCloudAutoSyncAnnouncementShown` | Dismiss cloud sync announcement | +| `themeOfTheMonthDismissedMonth` | Dismiss Theme of the Month popup | +| `justupdated` | Set `true` after extension update | +| `lastSeenNightlyPublishedAt` | Dismiss / acknowledge GitHub nightly update badge | +| `profile_picture_revision` | Incremented when profile picture changes | +| `plugin.notificationCollector.storage.*` | Written by notification poll loop | +| Legacy `animatedbk`, `bksliderinput`, etc. | Migrated to plugin settings; no longer shown in UI | + +### Synced plugin prefs with UI vs storage-only side effects + +| User action in UI | Synced | Not synced | +|-------------------|--------|------------| +| Upload profile picture | `useCloudPfp`, `profile_picture_revision` | Image blob (localforage) | +| Upload background music | `enabled`, `volume`, `pauseOnHidden` | Audio blob (localforage) | +| Install store theme | `selectedTheme`, often `selectedColor` / `DarkMode` | Theme assets (localforage) | +| Global Search indexing | Plugin settings toggles | IndexedDB `betterseqta-index`, `embeddiaDB` | +| Assessment averages | `lettergrade`, overrides | `storage.assessments`, `storage.weightings` caches | +| Grade Analytics TTL slider | `cacheTtlHours` | `bsplus.analytics.*` caches | + +### Never in settings UI (excluded from sync entirely) + +OAuth keys, analytics caches, assessment weighting caches, global-search storage prefix, client watermarks — see [Exclusion rules](#exclusion-rules). + +--- + +## Default schema initialization + +Implementation: `ensureSyncableStorageDefaults()` + `getSyncableStorageDefaults()`. + +### What gets written when a key is missing + +1. **All core `SettingsState` fields** from `getDefaultSettingsState()` — including `shortcuts`, `customshortcuts: []`, `menuitems`, `selectedFont: "rubik"`, `timeFormat: "24"`, announcement flags defaulting to `false`, etc. +2. **Every registered plugin’s** `plugin.{pluginId}.settings` object (defaults from plugin definitions; `enabled` included for `disableToggle` plugins). +3. **Legacy migration** on that flat map so upload-shaped storage uses `plugin.animated-background.settings` rather than `animatedbk` / `bksliderinput`. + +### What is not auto-initialized + +| Category | Reason | +|----------|--------| +| OAuth / session keys | Excluded from cloud; device-local | +| `plugin.*.storage.*` runtime data | Created when plugins run (folders, timetable overrides, etc.) | +| `bsplus.analytics.*` | Excluded device/school caches | +| `plugin.assessments-average.storage.assessments` / `.weightings` | Excluded school caches | +| `plugin.global-search.storage.*` | Excluded prefix | +| Client-only watermarks | Never uploaded | + +### Settings persistence and live UI + +`SettingsState` (`src/seqta/utils/listeners/SettingsState.ts`): + +- Assignments (`settingsState.foo = …`) and `setKey()` persist to `chrome.storage.local` and **notify registered listeners** in the same context (required for embedded SEQTA settings). +- `saveToStorage()` omits `undefined` values so optional keys are not accidentally stripped. diff --git a/src/background.ts b/src/background.ts index ad7ab92a..c1248182 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,6 +1,8 @@ import browser from "webextension-polyfill"; import semver from "semver"; import type { SettingsState } from "@/types/storage"; +import { getDefaultSettingsState } from "@/seqta/utils/defaultSettings"; +import { ensureSyncableStorageDefaults } from "@/seqta/utils/ensureSyncableStorageDefaults"; import { fetchNews } from "./background/news"; import { initCloudSettingsAutoSync, @@ -404,6 +406,15 @@ const MESSAGE_HANDLERS: Record = { void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" }); }, setDefaultStorage: () => SetStorageValue(getDefaultValues()), + ensureStorageDefaults: (_req, sendResponse) => { + void ensureSyncableStorageDefaults() + .then(() => sendResponse({ ok: true })) + .catch((e) => { + console.warn("[BetterSEQTA+] ensureStorageDefaults failed:", e); + sendResponse({ ok: false }); + }); + return true; + }, sendNews: (req, sendResponse) => { fetchNews(req.source ?? "australia", sendResponse); return true; @@ -477,76 +488,8 @@ browser.runtime.onMessage.addListener( }, ); -function detectLowEndDevice(): boolean { - // Check for low-end hardware indicators - const lowCoreCount = navigator.hardwareConcurrency && navigator.hardwareConcurrency < 4; - const lowMemory = (navigator as any).deviceMemory && (navigator as any).deviceMemory <= 2; - - return lowCoreCount || lowMemory; -} - function getDefaultValues(): SettingsState { - const isLowEndDevice = detectLowEndDevice(); - - return { - onoff: true, - animatedbk: true, - bksliderinput: "50", - transparencyEffects: false, - lessonalert: true, - defaultmenuorder: [], - menuitems: { - assessments: { toggle: true }, - courses: { toggle: true }, - dashboard: { toggle: true }, - documents: { toggle: true }, - forums: { toggle: true }, - goals: { toggle: true }, - home: { toggle: true }, - messages: { toggle: true }, - myed: { toggle: true }, - news: { toggle: true }, - notices: { toggle: true }, - portals: { toggle: true }, - reports: { toggle: true }, - settings: { toggle: true }, - timetable: { toggle: true }, - welcome: { toggle: true }, - }, - menuorder: [], - subjectfilters: {}, - selectedTheme: "", - selectedColor: - "linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)", - originalSelectedColor: "", - DarkMode: true, - animations: !isLowEndDevice, - assessmentsAverage: false, - defaultPage: "home", - shortcuts: [ - { - name: "Outlook", - enabled: true, - }, - { - name: "Office", - enabled: true, - }, - { - name: "Google", - enabled: true, - }, - ], - customshortcuts: [], - lettergrade: false, - newsSource: "australia", - iconOnlySidebar: false, - adaptiveThemeColour: false, - adaptiveThemeGradient: false, - adaptiveThemeColourTransition: true, - themeOfTheMonthDisabled: false, - autoCloudSettingsSync: true, - }; + return getDefaultSettingsState(); } function SetStorageValue(object: any) { @@ -673,6 +616,8 @@ browser.runtime.onInstalled.addListener(function (event) { browser.storage.local.remove(["justupdated"]); browser.storage.local.remove(["data"]); + void ensureSyncableStorageDefaults(); + if (event.reason == "install" || event.reason == "update") { browser.storage.local.set({ justupdated: true }); } @@ -684,4 +629,9 @@ browser.runtime.onInstalled.addListener(function (event) { } }); +browser.runtime.onStartup.addListener(() => { + void ensureSyncableStorageDefaults(); +}); + initCloudSettingsAutoSync({ reloadSeqtaPages }); +void ensureSyncableStorageDefaults(); diff --git a/src/css/injected/popup.scss b/src/css/injected/popup.scss index 6f64171d..5ecf0385 100644 --- a/src/css/injected/popup.scss +++ b/src/css/injected/popup.scss @@ -18,7 +18,7 @@ right: 10px; top: 80px; height: 590px; - z-index: 20; + z-index: 100; transition-duration: 100ms; } diff --git a/src/interface/pages/settings/shortcuts.svelte b/src/interface/pages/settings/shortcuts.svelte index b042b1a0..aaef38c8 100644 --- a/src/interface/pages/settings/shortcuts.svelte +++ b/src/interface/pages/settings/shortcuts.svelte @@ -24,18 +24,18 @@ }); const switchChange = (shortcut: any) => { - const idx = $settingsState.shortcuts.findIndex(s => s.name === shortcut); + const current = settingsState.shortcuts ?? []; + const idx = current.findIndex(s => s.name === shortcut); if (idx !== -1) { - // Create a new array with the toggled value to ensure reactivity - const updated = settingsState.shortcuts.map(s => + const updated = current.map(s => s.name === shortcut ? { ...s, enabled: !s.enabled } : s ); - settingsState.shortcuts = updated; + settingsState.setKey("shortcuts", updated); } else { - settingsState.shortcuts = [ - ...settingsState.shortcuts, - { name: shortcut, enabled: true } - ]; + settingsState.setKey("shortcuts", [ + ...current, + { name: shortcut, enabled: true }, + ]); } } @@ -82,7 +82,10 @@ if (isValidTitle(newTitle) && isValidURL(newURL)) { const icon = newIcon || newTitle[0]; const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon }; - settingsState.customshortcuts = [...settingsState.customshortcuts, newShortcut]; + settingsState.setKey("customshortcuts", [ + ...(settingsState.customshortcuts ?? []), + newShortcut, + ]); newTitle = ""; newURL = ""; @@ -94,7 +97,10 @@ }; const deleteCustomShortcut = (index: number) => { - settingsState.customshortcuts = settingsState.customshortcuts.filter((_, i) => i !== index); + settingsState.setKey( + "customshortcuts", + (settingsState.customshortcuts ?? []).filter((_, i) => i !== index), + ); }; @@ -203,7 +209,7 @@ - {#each $settingsState.customshortcuts as shortcut, index} + {#each ($settingsState.customshortcuts ?? []) as shortcut, index}
{shortcut.name} {/if}

- GitHub release build — do not upload to extension stores. + {#if ghReleaseChannelLabel} + {ghReleaseChannelLabel} — do not upload to extension stores. + {:else} + GitHub release build — do not upload to extension stores. + {/if}

{/if} diff --git a/src/utils/githubReleaseUpdate.ts b/src/utils/githubReleaseUpdate.ts index 78aa1d0a..e0d3a5fe 100644 --- a/src/utils/githubReleaseUpdate.ts +++ b/src/utils/githubReleaseUpdate.ts @@ -35,6 +35,14 @@ function getBuildLabel(): string { return typeof __BUILD_LABEL__ !== "undefined" ? __BUILD_LABEL__ : ""; } +function formatNightlyLabel(buildDate: string): string { + return buildDate ? `nightly (${buildDate})` : "nightly"; +} + +function nightlyDateFromPublishedAt(publishedAt: string): string { + return publishedAt.slice(0, 10); +} + function getCurrentVersion(): string { return browser.runtime.getManifest().version; } @@ -158,8 +166,7 @@ async function checkNightlyUpdate(): Promise { } const lastSeen = settingsState.lastSeenNightlyPublishedAt; - const buildLabel = getBuildLabel(); - const label = buildLabel ? `nightly #${buildLabel}` : "nightly"; + const label = formatNightlyLabel(nightlyDateFromPublishedAt(release.published_at)); if (!lastSeen) { settingsState.lastSeenNightlyPublishedAt = release.published_at; @@ -177,6 +184,12 @@ export function isGhReleaseUpdateCheckEnabled(): boolean { return isUpdateCheckEnabled(); } +/** Label for the installed GitHub release build (e.g. `nightly (2025-06-10)`). */ +export function getInstalledGhReleaseChannelLabel(): string | null { + if (!isUpdateCheckEnabled() || getUpdateChannel() !== "nightly") return null; + return formatNightlyLabel(getBuildLabel()); +} + export async function checkGithubReleaseUpdate(): Promise { const fallback = { available: false, label: "", url: releasesBaseUrl() }; From 57a1965a6d8b2a3b81b53bca4da54ec1f84adf34 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Wed, 10 Jun 2026 11:00:09 +0930 Subject: [PATCH 3/3] add aden's requested changes --- .github/workflows/pr-ci.yml | 6 ++ docs/CLOUD_SETTINGS_SYNC_OPTIONS.md | 30 +++---- docs/CLOUD_SETTINGS_SYNC_SERVER.md | 17 ++-- jest.config.js | 4 + package.json | 5 ++ scripts/smoke-test.mjs | 49 +++++++++++ src/background/cloudSettingsAutoSync.ts | 50 +++++++++-- src/seqta/utils/CloudAuth.ts | 2 + .../utils/cloudSettingsSync.legacy.test.ts | 75 ++++++++++++++++ .../utils/cloudSettingsSync.patch.test.ts | 88 +++++++++++++++++++ src/seqta/utils/cloudSettingsSync.ts | 84 +++++++++++++++--- .../utils/ensureSyncableStorageDefaults.ts | 2 +- src/test/mocks/webextension-polyfill.ts | 35 ++++++++ 13 files changed, 403 insertions(+), 44 deletions(-) create mode 100644 scripts/smoke-test.mjs create mode 100644 src/seqta/utils/cloudSettingsSync.legacy.test.ts create mode 100644 src/seqta/utils/cloudSettingsSync.patch.test.ts create mode 100644 src/test/mocks/webextension-polyfill.ts diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index e2d567fe..dbc5ab58 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -23,7 +23,13 @@ jobs: env: ESLINT_USE_FLAT_CONFIG: "false" + - name: Unit tests + run: npm test + - name: Build extension uses: ./.github/actions/build-extension with: gh_release_update_check: "false" + + - name: Smoke tests + run: npm run test:smoke diff --git a/docs/CLOUD_SETTINGS_SYNC_OPTIONS.md b/docs/CLOUD_SETTINGS_SYNC_OPTIONS.md index 34d88bbc..69b9393a 100644 --- a/docs/CLOUD_SETTINGS_SYNC_OPTIONS.md +++ b/docs/CLOUD_SETTINGS_SYNC_OPTIONS.md @@ -47,11 +47,11 @@ Cloud settings sync is a **whole-snapshot backup** of extension local storage: │ │ menuitems… │ │ │ │ │ │ │ └─────────────┘ └──────────────┘ └────────────────────────┘ │ └────────────────────────────┬────────────────────────────────────┘ - │ buildUploadPayload() - │ • filter exclusions - │ • migrateLegacyToPluginSettings() + │ buildUploadPatch() + │ • ensureSyncableStorageDefaults (local) + │ • normalize + diff vs last upload / defaults ▼ - PUT /api/bsplus/settings/sync + PUT /api/bsplus/settings/sync (sparse data patch) { schemaVersion, themeId, data: { … } } │ ▼ @@ -108,9 +108,9 @@ Download is skipped when server `schemaVersion` > client `CLOUD_SETTINGS_SYNC_SC After download, if `themeId` / `selectedTheme` is non-empty, the service worker sets `bsplus_pending_theme_ensure_after_cloud` so the page `ThemeManager` can download missing theme assets from the store. -### Full schema before upload +### Local schema before diff (upload) -`ensureSyncableStorageDefaults()` (`src/seqta/utils/ensureSyncableStorageDefaults.ts`) ensures every **cloud-syncable** key exists in `chrome.storage.local` with its default value if it was previously absent. This makes uploads and dev JSON exports contain a complete schema (e.g. `customshortcuts: []`, `shortcuts: [...]`, every `plugin.{id}.settings` object) instead of omitting keys the user never touched. +`ensureSyncableStorageDefaults()` (`src/seqta/utils/ensureSyncableStorageDefaults.ts`) ensures every **cloud-syncable** key exists in `chrome.storage.local` with its default value if it was previously absent. This runs **locally only** so diffs against the last-uploaded snapshot (or schema defaults) are consistent. The PUT body is a **sparse patch** of changed keys only — defaults are not bulk-uploaded. | When it runs | Context | |--------------|---------| @@ -118,6 +118,7 @@ After download, if `themeId` / `selectedTheme` is non-empty, the service worker | `browser.runtime.onStartup` | Service worker | | Service worker load | `background.ts` (once at startup) | | `initializeSettingsState()` | SEQTA content script + extension settings page (first init) | +| Before each cloud upload | `putSettingsOnce()` in `cloudSettingsAutoSync.ts` | Rules: @@ -146,22 +147,14 @@ Content-Type: application/json "schemaVersion": 1, "themeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "data": { - "onoff": true, "DarkMode": true, - "selectedTheme": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "selectedColor": "linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)", - "plugin.global-search.settings": { - "enabled": true, - "searchHotkey": "ctrl+k", - "showRecentFirst": true, - "transparencyEffects": true, - "runIndexingOnLoad": true, - "passiveIndexing": true - } + "selectedTheme": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } } ``` +Only keys that changed since the last successful upload appear in `data`. The server merges this patch into stored JSON; omitted keys are not deleted. Dev JSON export (`getSnapshotForUpload`) still returns the full normalized map for debugging. + | Top-level field | JSON type | Rules | |-----------------|-----------|-------| | `schemaVersion` | `number` | Always `1` today (`CLOUD_SETTINGS_SYNC_SCHEMA_VERSION`) | @@ -238,6 +231,7 @@ Implemented in `shouldOmitKeyFromCloudPayload(key)`: | `plugin.assessments-average.storage.assessments` | object | Keep device value | | `plugin.assessments-average.storage.weightings` | object | Keep device value | | `bsplus_cloud_settings_known_remote_updated_at` | ISO `string` | Keep device value | +| `bsplus_cloud_settings_last_uploaded_snapshot` | `object` (normalized syncable map) | Keep device value | | `bsplus_lastCloudPoll` | `number` (ms) | Keep device value | | `bsplus_pending_theme_ensure_after_cloud` | `string` (theme id) | Keep device value | @@ -1061,7 +1055,7 @@ Any new `chrome.storage.local` key syncs automatically unless added to: 2. At least one changed key passes `isKeyIncludedInCloudUploadPayload` 3. `autoCloudSettingsSync !== false` 4. Valid access token -5. Not during restore → schedule 2 s debounce → `PUT` full snapshot +5. Not during restore → schedule 2 s debounce → `PUT` sparse patch (skip if empty) ### Server storage suggestion diff --git a/docs/CLOUD_SETTINGS_SYNC_SERVER.md b/docs/CLOUD_SETTINGS_SYNC_SERVER.md index 2a57d8cd..bf64f30a 100644 --- a/docs/CLOUD_SETTINGS_SYNC_SERVER.md +++ b/docs/CLOUD_SETTINGS_SYNC_SERVER.md @@ -28,7 +28,7 @@ Use the same **access tokens** issued by the existing BetterSEQTA+ OAuth flows ( ### `PUT /api/bsplus/settings/sync` -Upserts the caller’s settings backup. +Upserts the caller’s settings backup. The server **merges** `data` into the stored JSON document; keys omitted from the patch are **not** deleted. **Request body (JSON):** @@ -37,12 +37,14 @@ Upserts the caller’s settings backup. "schemaVersion": 1, "themeId": "uuid-string-or-empty", "data": { - "...": "flat key-value map mirroring extension storage (see Payload shape)", + "DarkMode": true, "selectedTheme": "uuid-or-empty-string" } } ``` +The extension sends a **sparse patch**: only keys that changed since the last successful upload (or, on first upload, keys that differ from schema defaults). A full snapshot is not required. + - **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations. - **`themeId`**: optional but recommended duplicate of **`data.selectedTheme`**. Should be the UUID of the **installed** BetterSEQTA store theme (`selectedTheme`). This may be a normal theme id **or** a **flavour (slave) variant** id from themes with **`flavours[]`** — the extension uses it after restore to prefetch `theme.json` when missing locally (same **`GET …/themes/{id}/download`** as the store UI). Persist and return **`themeId`** in sync with **`data.selectedTheme`**. - **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`). @@ -51,11 +53,12 @@ Upserts the caller’s settings backup. ```json { - "updated_at": "2026-04-07T12:00:00.000Z" + "updated_at": "2026-04-07T12:00:00.000Z", + "patch": { "DarkMode": true } } ``` -`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields. +`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields. Optional `patch` may echo the merged keys applied server-side. **Error responses:** Standard JSON error body if applicable, e.g. `{ "error": "message" }`, with appropriate HTTP status (`401`, `413`, `422`, etc.). @@ -103,7 +106,8 @@ Unique constraint on `user_id`. ## Semantics -- **Last write wins:** each `PUT` replaces the stored backup for that user. +- **Merge on PUT:** each `PUT` deep-merges `data` into the stored backup for that user. Keys not present in the request body remain unchanged on the server. +- **Full document on GET:** restore and poll download still receive the complete hydrated settings object. - **Optional later:** `If-Unmodified-Since` or a `revision` field for conflict detection (not required for v1). ## Security and privacy @@ -121,6 +125,7 @@ The backup is a flat JSON map of **`chrome.storage.local`** keys. It does **not* - **Assessment Averages caches** — `plugin.assessments-average.storage.assessments`, `plugin.assessments-average.storage.weightings` (school assessment data). - **Keys under** `plugin.global-search.storage.*` — reserved so any future plugin storage cache there is not synced. - **Grade Analytics** — keys under `bsplus.analytics.*` (synced assessment cache and per-school chart preferences). +- **`bsplus_cloud_settings_last_uploaded_snapshot`** — client-only normalized map last acked by PUT; used to compute sparse upload patches (not part of the cloud backup blob). - **`bsplus_cloud_settings_known_remote_updated_at`** — client-only watermark for auto-sync (not part of the cloud backup blob). On restore, those keys are **not** taken from the server; the device keeps its current local values. @@ -136,6 +141,6 @@ This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages, ## Client reference (extension) -- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, **`bsplus_pending_theme_ensure_after_cloud`**, and **`bsplus_cloud_settings_known_remote_updated_at`** — includes **`themeId`** aligned with **`selectedTheme`**). +- Upload (sparse patch): `buildUploadPatch` / `putSettingsOnce` in `src/background/cloudSettingsAutoSync.ts`; baseline from `bsplus_cloud_settings_last_uploaded_snapshot` or schema defaults; dev full export via `getSnapshotForUpload` / `buildUploadPayload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, client-only metadata — includes **`themeId`** aligned with **`selectedTheme`**). - Download: resolve id via **`resolveThemeIdForPostSyncDownload`** → **`applyDownloadedEnvelope`** after `GET` → prefetch theme blobs in page context if needed (**`prepareThemeAfterCloudSync`** in **`ThemeManager`**) → reload SEQTA tabs; local auth keys, sensitive device keys, client-only watermark, and **`bsplus_pending_theme_ensure_after_cloud`** semantics preserved as documented above. - Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`. diff --git a/jest.config.js b/jest.config.js index 7e690b7b..428e2515 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,6 +9,10 @@ export default { transform: { '^.+\\.ts$': 'ts-jest', }, + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '^webextension-polyfill$': '/src/test/mocks/webextension-polyfill.ts', + }, moduleFileExtensions: ['ts', 'js', 'json'], collectCoverageFrom: [ 'src/**/*.ts', diff --git a/package.json b/package.json index fad5924b..c7c04fc9 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari", "dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", "lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint \"src/**/*.{js,ts}\"", + "test": "jest", + "test:smoke": "node scripts/smoke-test.mjs", "release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes", "publish": "bun lib/publish.js --b", "zip": "bedframe zip" @@ -43,6 +45,7 @@ "@crxjs/vite-plugin": "^2.4.0", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", + "@types/jest": "^30.0.0", "@types/mime-types": "^3.0.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", @@ -53,6 +56,7 @@ "eslint": "^9.33.0", "eslint-plugin-import": "^2.31.0", "glob": "^11.0.1", + "jest": "^30.4.2", "mime-types": "^3.0.1", "prettier": "^3.5.3", "process": "^0.11.10", @@ -61,6 +65,7 @@ "sass-loader": "^16.0.5", "semver": "^7.7.1", "tailwindcss": "3", + "ts-jest": "^29.4.11", "url": "^0.11.4" }, "dependencies": { diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs new file mode 100644 index 00000000..a19d3e16 --- /dev/null +++ b/scripts/smoke-test.mjs @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); + +function fail(message) { + console.error(`[smoke-test] ${message}`); + process.exit(1); +} + +function assertManifest(browserDir) { + const manifestPath = path.join(root, "dist", browserDir, "manifest.json"); + if (!fs.existsSync(manifestPath)) { + fail(`Missing ${manifestPath} — run npm run build first`); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (!manifest.manifest_version) fail(`${browserDir} manifest missing manifest_version`); + if (!manifest.name) fail(`${browserDir} manifest missing name`); + if (!manifest.version) fail(`${browserDir} manifest missing version`); + + const sw = + manifest.background?.service_worker ?? + manifest.background?.scripts?.[0] ?? + null; + if (sw) { + const swPath = path.join(root, "dist", browserDir, sw); + if (!fs.existsSync(swPath)) { + fail(`${browserDir} service worker not found on disk: ${sw}`); + } + } +} + +function assertAssets(browserDir) { + const assetsDir = path.join(root, "dist", browserDir, "assets"); + if (!fs.existsSync(assetsDir)) { + fail(`Missing assets directory: dist/${browserDir}/assets`); + } + const jsFiles = fs.readdirSync(assetsDir).filter((f) => f.endsWith(".js")); + if (jsFiles.length === 0) { + fail(`No JS assets in dist/${browserDir}/assets`); + } +} + +for (const browser of ["chrome", "firefox"]) { + assertManifest(browser); + assertAssets(browser); +} + +console.log("[smoke-test] dist/chrome and dist/firefox look OK"); diff --git a/src/background/cloudSettingsAutoSync.ts b/src/background/cloudSettingsAutoSync.ts index 1ac8433f..37224124 100644 --- a/src/background/cloudSettingsAutoSync.ts +++ b/src/background/cloudSettingsAutoSync.ts @@ -1,12 +1,19 @@ import browser from "webextension-polyfill"; +import { + ensureSyncableStorageDefaults, + getSyncableStorageDefaults, +} from "@/seqta/utils/ensureSyncableStorageDefaults"; import { applyDownloadedEnvelope, - buildUploadPayload, BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY, + buildUploadPatch, CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, isKeyIncludedInCloudUploadPayload, + normalizeStorageForSync, resolveThemeIdForPostSyncDownload, + saveLastUploadedSnapshot, + getLastUploadedSnapshot, setKnownRemoteUpdatedAt, } from "@/seqta/utils/cloudSettingsSync"; @@ -138,13 +145,38 @@ async function fetchCloudSummaryWithAuthRetry( } type PutResult = - | { ok: true; updated_at?: string } + | { ok: true; updated_at?: string; skipped?: boolean } | { ok: false; unauthorized: boolean; error?: string }; +async function resolveUploadBaseline( + normalized: Record, + watermark: string | undefined, +): Promise> { + const lastSnapshot = await getLastUploadedSnapshot(); + if (lastSnapshot) return lastSnapshot; + + if (watermark) { + await saveLastUploadedSnapshot(normalized); + return normalized; + } + + return getSyncableStorageDefaults(); +} + async function putSettingsOnce(token: string): Promise { try { - const all = await browser.storage.local.get(); - const payload = buildUploadPayload(all as Record); + await ensureSyncableStorageDefaults(); + + const all = (await browser.storage.local.get()) as Record; + const normalized = normalizeStorageForSync(all); + const watermark = all[BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as string | undefined; + const baseline = await resolveUploadBaseline(normalized, watermark); + const payload = buildUploadPatch(all, baseline); + + if (!payload) { + return { ok: true, skipped: true }; + } + const r = await fetch(CLOUD_SETTINGS_SYNC_URL, { method: "PUT", headers: { @@ -163,6 +195,7 @@ async function putSettingsOnce(token: string): Promise { }; } const updated_at = data?.updated_at as string | undefined; + await saveLastUploadedSnapshot(normalized); await setKnownRemoteUpdatedAt(updated_at); return { ok: true, updated_at }; } catch (e) { @@ -176,11 +209,13 @@ async function putSettingsOnce(token: string): Promise { export async function performCloudSettingsUploadWithRetry( token: string, -): Promise<{ success: boolean; error?: string; updated_at?: string }> { +): Promise<{ success: boolean; error?: string; updated_at?: string; skipped?: boolean }> { let t = token; for (let attempt = 0; attempt < 2; attempt++) { const res = await putSettingsOnce(t); - if (res.ok) return { success: true, updated_at: res.updated_at }; + if (res.ok) { + return { success: true, updated_at: res.updated_at, skipped: res.skipped }; + } if (res.unauthorized && attempt === 0) { const refreshed = await tryRefreshTokens(); if (!refreshed) return { success: false, error: "Not authenticated" }; @@ -234,6 +269,9 @@ async function getSettingsAndApplyOnce(token: string): Promise { reloadSeqtaPagesFn?.(); const updated_at = data?.updated_at as string | undefined; await setKnownRemoteUpdatedAt(updated_at); + await saveLastUploadedSnapshot( + normalizeStorageForSync((await browser.storage.local.get()) as Record), + ); return { ok: true, updated_at }; } catch (e) { return { diff --git a/src/seqta/utils/CloudAuth.ts b/src/seqta/utils/CloudAuth.ts index 29cd4617..0a178c94 100644 --- a/src/seqta/utils/CloudAuth.ts +++ b/src/seqta/utils/CloudAuth.ts @@ -1,5 +1,6 @@ import browser from "webextension-polyfill"; import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache"; +import { clearLastUploadedSnapshot } from "@/seqta/utils/cloudSettingsSync"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback"; @@ -205,6 +206,7 @@ class CloudAuthService { public async logout(): Promise { const userId = this._state.user?.id; if (userId) await clearCloudPfpCache(userId); + await clearLastUploadedSnapshot(); await browser.storage.local.remove([ STORAGE_KEYS.accessToken, STORAGE_KEYS.refreshToken, diff --git a/src/seqta/utils/cloudSettingsSync.legacy.test.ts b/src/seqta/utils/cloudSettingsSync.legacy.test.ts new file mode 100644 index 00000000..d4cbcbe9 --- /dev/null +++ b/src/seqta/utils/cloudSettingsSync.legacy.test.ts @@ -0,0 +1,75 @@ +import { + isKeyIncludedInCloudUploadPayload, + migrateLegacyToPluginSettings, + normalizeThemeIdForSync, + resolveThemeIdForPostSyncDownload, +} from "./cloudSettingsSync"; + +describe("migrateLegacyToPluginSettings", () => { + it("maps animatedbk without overwriting existing plugin fields", () => { + const result = migrateLegacyToPluginSettings({ + animatedbk: true, + "plugin.animated-background.settings": { speed: 1.5 }, + }); + expect(result["plugin.animated-background.settings"]).toEqual({ + speed: 1.5, + enabled: true, + }); + expect(result).not.toHaveProperty("animatedbk"); + }); + + it("does not set enabled when legacy key is absent", () => { + const result = migrateLegacyToPluginSettings({ + "plugin.animated-background.settings": { speed: 1.0 }, + }); + expect(result["plugin.animated-background.settings"]).toEqual({ speed: 1.0 }); + }); +}); + +describe("isKeyIncludedInCloudUploadPayload", () => { + it("excludes auth and device cache prefixes", () => { + expect(isKeyIncludedInCloudUploadPayload("bsplus_token")).toBe(false); + expect(isKeyIncludedInCloudUploadPayload("plugin.global-search.storage.index")).toBe( + false, + ); + expect(isKeyIncludedInCloudUploadPayload("bsplus.analytics.v2.school.1")).toBe(false); + }); + + it("includes core and plugin settings keys", () => { + expect(isKeyIncludedInCloudUploadPayload("DarkMode")).toBe(true); + expect(isKeyIncludedInCloudUploadPayload("plugin.profile-picture.settings")).toBe(true); + }); +}); + +describe("resolveThemeIdForPostSyncDownload", () => { + it("prefers top-level themeId over data.selectedTheme", () => { + expect( + resolveThemeIdForPostSyncDownload({ + themeId: " top-id ", + data: { selectedTheme: "other-id" }, + }), + ).toBe("top-id"); + }); + + it("falls back to data.selectedTheme", () => { + expect( + resolveThemeIdForPostSyncDownload({ + data: { selectedTheme: "from-data" }, + }), + ).toBe("from-data"); + }); + + it("returns undefined when theme id is empty", () => { + expect( + resolveThemeIdForPostSyncDownload({ + data: { selectedTheme: " " }, + }), + ).toBeUndefined(); + }); +}); + +describe("normalizeThemeIdForSync", () => { + it("normalizes envelope theme ids consistently", () => { + expect(normalizeThemeIdForSync(" uuid ")).toBe("uuid"); + }); +}); diff --git a/src/seqta/utils/cloudSettingsSync.patch.test.ts b/src/seqta/utils/cloudSettingsSync.patch.test.ts new file mode 100644 index 00000000..a4523129 --- /dev/null +++ b/src/seqta/utils/cloudSettingsSync.patch.test.ts @@ -0,0 +1,88 @@ +import { + buildUploadPatch, + CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, + diffSyncableStorage, + normalizeStorageForSync, + normalizeThemeIdForSync, +} from "./cloudSettingsSync"; + +describe("normalizeStorageForSync", () => { + it("strips omitted auth and client-only keys", () => { + const normalized = normalizeStorageForSync({ + DarkMode: true, + bsplus_token: "secret", + bsplus_cloud_settings_known_remote_updated_at: "2026-01-01T00:00:00.000Z", + "bsplus.analytics.v2.school.1": { cached: true }, + }); + expect(normalized).toEqual({ DarkMode: true }); + }); + + it("migrates legacy animatedbk to plugin settings", () => { + const normalized = normalizeStorageForSync({ animatedbk: true }); + expect(normalized).toEqual({ + "plugin.animated-background.settings": { enabled: true }, + }); + }); +}); + +describe("diffSyncableStorage", () => { + it("returns empty object when maps are identical", () => { + const map = { DarkMode: true, onoff: true }; + expect(diffSyncableStorage(map, { ...map })).toEqual({}); + }); + + it("includes only changed scalar keys", () => { + const current = { DarkMode: false, onoff: true }; + const baseline = { DarkMode: true, onoff: true }; + expect(diffSyncableStorage(current, baseline)).toEqual({ DarkMode: false }); + }); + + it("includes whole plugin object when nested value changes", () => { + const current = { + "plugin.global-search.settings": { enabled: true, searchHotkey: "ctrl+k" }, + }; + const baseline = { + "plugin.global-search.settings": { enabled: false, searchHotkey: "ctrl+k" }, + }; + expect(diffSyncableStorage(current, baseline)).toEqual({ + "plugin.global-search.settings": { enabled: true, searchHotkey: "ctrl+k" }, + }); + }); + + it("does not emit keys removed locally (absent from current)", () => { + const current = { DarkMode: true }; + const baseline = { DarkMode: true, onoff: true }; + expect(diffSyncableStorage(current, baseline)).toEqual({}); + }); +}); + +describe("buildUploadPatch", () => { + it("returns null when current matches baseline", () => { + const all = { DarkMode: true, selectedTheme: "" }; + const baseline = { DarkMode: true, selectedTheme: "" }; + expect(buildUploadPatch(all, baseline)).toBeNull(); + }); + + it("returns sparse envelope when values differ", () => { + const all = { + DarkMode: false, + selectedTheme: "theme-uuid", + bsplus_token: "ignore-me", + }; + const baseline = { DarkMode: true, selectedTheme: "theme-uuid" }; + const patch = buildUploadPatch(all, baseline); + expect(patch).toEqual({ + schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, + themeId: "theme-uuid", + data: { DarkMode: false }, + }); + }); +}); + +describe("normalizeThemeIdForSync", () => { + it("trims whitespace and returns empty for non-strings", () => { + expect(normalizeThemeIdForSync(" abc ")).toBe("abc"); + expect(normalizeThemeIdForSync(undefined)).toBe(""); + expect(normalizeThemeIdForSync(" ")).toBe(""); + }); +}); diff --git a/src/seqta/utils/cloudSettingsSync.ts b/src/seqta/utils/cloudSettingsSync.ts index 4c41857a..4f2ba31b 100644 --- a/src/seqta/utils/cloudSettingsSync.ts +++ b/src/seqta/utils/cloudSettingsSync.ts @@ -1,4 +1,5 @@ import browser from "webextension-polyfill"; +import isEqual from "lodash/isEqual"; /** Matches the contract in docs/CLOUD_SETTINGS_SYNC_SERVER.md */ export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1; @@ -17,6 +18,13 @@ export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY = export const BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY = "bsplus_pending_theme_ensure_after_cloud"; +/** + * Client-only: normalized syncable storage last acked by a successful PUT. + * Never uploaded; used to compute sparse upload patches. + */ +export const BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY = + "bsplus_cloud_settings_last_uploaded_snapshot"; + /** * Never uploaded to the cloud backup (OAuth and legacy keys). * IndexedDB (e.g. Global Search’s `betterseqta-index` database) is not part of @@ -48,6 +56,7 @@ export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = [ const CLIENT_ONLY_CLOUD_KEYS_EXACT = [ BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, + BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY, "bsplus_lastCloudPoll", BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY, ] as const; @@ -118,30 +127,79 @@ export function normalizeThemeIdForSync(raw: unknown): string { return raw.trim(); } -export function buildUploadPayload(all: Record): { - schemaVersion: number; - themeId: string; - data: Record; -} { +/** Filter omit lists and migrate legacy keys → full syncable map for diff/export. */ +export function normalizeStorageForSync(all: Record): Record { const filtered: Record = {}; for (const [k, v] of Object.entries(all)) { if (shouldOmitKeyFromCloudPayload(k)) continue; filtered[k] = v; } - const data = migrateLegacyToPluginSettings(filtered); - const themeId = normalizeThemeIdForSync(all.selectedTheme); + return migrateLegacyToPluginSettings(filtered); +} + +/** Keys in `current` whose values differ from `baseline` (sparse PUT body). */ +export function diffSyncableStorage( + current: Record, + baseline: Record, +): Record { + const patch: Record = {}; + for (const [key, value] of Object.entries(current)) { + if (!isEqual(value, baseline[key])) { + patch[key] = value; + } + } + return patch; +} + +export type CloudSettingsUploadEnvelope = { + schemaVersion: number; + themeId: string; + data: Record; +}; + +/** Sparse upload envelope, or null when nothing changed vs baseline. */ +export function buildUploadPatch( + all: Record, + baseline: Record, +): CloudSettingsUploadEnvelope | null { + const normalized = normalizeStorageForSync(all); + const data = diffSyncableStorage(normalized, baseline); + if (Object.keys(data).length === 0) return null; return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, - themeId, + themeId: normalizeThemeIdForSync(all.selectedTheme), data, }; } -export async function getSnapshotForUpload(): Promise<{ - schemaVersion: number; - themeId: string; - data: Record; -}> { +/** Full normalized snapshot (dev export / debugging). */ +export function buildUploadPayload(all: Record): CloudSettingsUploadEnvelope { + const data = normalizeStorageForSync(all); + return { + schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, + themeId: normalizeThemeIdForSync(all.selectedTheme), + data, + }; +} + +export async function getLastUploadedSnapshot(): Promise | null> { + const stored = await browser.storage.local.get(BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY); + const snapshot = stored[BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY]; + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null; + return snapshot as Record; +} + +export async function saveLastUploadedSnapshot( + snapshot: Record, +): Promise { + await browser.storage.local.set({ [BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY]: snapshot }); +} + +export async function clearLastUploadedSnapshot(): Promise { + await browser.storage.local.remove(BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY); +} + +export async function getSnapshotForUpload(): Promise { const all = await browser.storage.local.get(); return buildUploadPayload(all as Record); } diff --git a/src/seqta/utils/ensureSyncableStorageDefaults.ts b/src/seqta/utils/ensureSyncableStorageDefaults.ts index 4d91bb8b..efa0d82f 100644 --- a/src/seqta/utils/ensureSyncableStorageDefaults.ts +++ b/src/seqta/utils/ensureSyncableStorageDefaults.ts @@ -84,7 +84,7 @@ function mergePluginSettingsDefaults( } /** - * Writes any missing cloud-syncable keys so uploads contain a full schema. + * Writes any missing cloud-syncable keys locally for consistent diffing. * Never overwrites existing values. Missing plugin settings respect legacy keys. */ export async function ensureSyncableStorageDefaults(): Promise { diff --git a/src/test/mocks/webextension-polyfill.ts b/src/test/mocks/webextension-polyfill.ts new file mode 100644 index 00000000..6171d913 --- /dev/null +++ b/src/test/mocks/webextension-polyfill.ts @@ -0,0 +1,35 @@ +const storage = new Map(); + +const local = { + get: jest.fn(async (keys?: string | string[] | null) => { + if (keys == null) { + return Object.fromEntries(storage); + } + if (typeof keys === "string") { + return keys in storage ? { [keys]: storage.get(keys) } : {}; + } + const out: Record = {}; + for (const key of keys) { + if (storage.has(key)) out[key] = storage.get(key); + } + return out; + }), + set: jest.fn(async (items: Record) => { + for (const [k, v] of Object.entries(items)) storage.set(k, v); + }), + remove: jest.fn(async (keys: string | string[]) => { + const list = Array.isArray(keys) ? keys : [keys]; + for (const key of list) storage.delete(key); + }), +}; + +export default { + storage: { local }, +}; + +export function __resetBrowserStorageMock() { + storage.clear(); + local.get.mockClear(); + local.set.mockClear(); + local.remove.mockClear(); +}