diff --git a/.github/actions/build-extension/action.yml b/.github/actions/build-extension/action.yml index adb6ac1a..c5368033 100644 --- a/.github/actions/build-extension/action.yml +++ b/.github/actions/build-extension/action.yml @@ -11,7 +11,7 @@ inputs: required: false default: stable build_label: - description: Optional build label for nightly display (e.g. run number). + description: Optional build label for nightly display (e.g. UTC build date). required: false default: "" release_repo: @@ -59,9 +59,17 @@ runs: - name: Package zips id: zip shell: bash + env: + UPDATE_CHANNEL: ${{ inputs.update_channel }} + BUILD_LABEL: ${{ inputs.build_label }} run: | VERSION="${{ steps.version.outputs.version }}" - (cd dist/chrome && zip -r "../betterseqtaplus-${VERSION}-chrome.zip" .) - (cd dist/firefox && zip -r "../betterseqtaplus-${VERSION}-firefox.zip" .) - echo "chrome_zip=dist/betterseqtaplus-${VERSION}-chrome.zip" >> "$GITHUB_OUTPUT" - echo "firefox_zip=dist/betterseqtaplus-${VERSION}-firefox.zip" >> "$GITHUB_OUTPUT" + if [ "$UPDATE_CHANNEL" = "nightly" ] && [ -n "$BUILD_LABEL" ]; then + BASE="betterseqtaplus-nightly-${BUILD_LABEL}" + else + BASE="betterseqtaplus-${VERSION}" + fi + (cd dist/chrome && zip -r "../${BASE}-chrome.zip" .) + (cd dist/firefox && zip -r "../${BASE}-firefox.zip" .) + echo "chrome_zip=dist/${BASE}-chrome.zip" >> "$GITHUB_OUTPUT" + echo "firefox_zip=dist/${BASE}-firefox.zip" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9da16900..5974de2c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -20,22 +20,29 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set build date + id: build_date + run: echo "date=$(date -u +'%Y-%m-%d')" >> "$GITHUB_OUTPUT" + - name: Build extension id: build uses: ./.github/actions/build-extension with: gh_release_update_check: "true" update_channel: nightly - build_label: ${{ github.run_number }} + build_label: ${{ steps.build_date.outputs.date }} release_repo: ${{ github.repository }} - name: Ensure nightly release exists run: | + TITLE="Nightly (${{ steps.build_date.outputs.date }})" if ! gh release view "${{ env.NIGHTLY_TAG }}" 2>/dev/null; then gh release create "${{ env.NIGHTLY_TAG }}" \ --prerelease \ - --title "Nightly" \ + --title "$TITLE" \ --notes-file .github/nightly-release-notes.md + else + gh release edit "${{ env.NIGHTLY_TAG }}" --title "$TITLE" fi - name: Upload nightly assets 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 new file mode 100644 index 00000000..69b9393a --- /dev/null +++ b/docs/CLOUD_SETTINGS_SYNC_OPTIONS.md @@ -0,0 +1,1224 @@ +# 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… │ │ │ │ │ │ +│ └─────────────┘ └──────────────┘ └────────────────────────┘ │ +└────────────────────────────┬────────────────────────────────────┘ + │ buildUploadPatch() + │ • ensureSyncableStorageDefaults (local) + │ • normalize + diff vs last upload / defaults + ▼ + PUT /api/bsplus/settings/sync (sparse data patch) + { 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. + +### 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 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 | +|--------------|---------| +| `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) | +| Before each cloud upload | `putSettingsOnce()` in `cloudSettingsAutoSync.ts` | + +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": { + "DarkMode": 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`) | +| `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_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 | + +#### `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` sparse patch (skip if empty) + +### 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/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/docs/RELEASES.md b/docs/RELEASES.md index 3d06a2ba..1df9a097 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -71,7 +71,7 @@ Release and nightly workflows pass environment variables into Vite, which bakes | `GH_RELEASE_UPDATE_CHECK` | `true` | `true` | `false` / unset | | `UPDATE_CHANNEL` | `stable` | `nightly` | `stable` (unused) | | `GH_RELEASE_REPO` | `BetterSEQTA/BetterSEQTA-Plus` | same | same | -| `BUILD_LABEL` | empty | GitHub run number | empty | +| `BUILD_LABEL` | empty | UTC build date (`YYYY-MM-DD`) | empty | When `GH_RELEASE_UPDATE_CHECK` is not `true`, the update-checker code is tree-shaken out of the bundle. PR CI builds and local `npm run build` do **not** include the update badge. @@ -139,7 +139,8 @@ On first publish, edit the **title** and **release notes** on GitHub afterward. 1. Builds from the current `main` branch with the update detector enabled (nightly channel). 2. Uses a fixed release tag: **`nightly`** (marked as prerelease). 3. On first run: creates the `nightly` release with the text in [`.github/nightly-release-notes.md`](../.github/nightly-release-notes.md). -4. On every subsequent run: **replaces** the zip assets on the same release (`--clobber`). The release title and body are not rewritten. +4. On every run: sets the release title to **`Nightly (YYYY-MM-DD)`** (UTC build date) and **replaces** the zip assets on the same release (`--clobber`). The release body is not rewritten. +5. Packages zips as `betterseqtaplus-nightly-{date}-chrome.zip` / `-firefox.zip`. The nightly release body warns that builds are experimental and must not be uploaded to extension stores. @@ -206,7 +207,7 @@ Implementation: [`src/utils/githubReleaseUpdate.ts`](../src/utils/githubReleaseU 1. Fetches the `nightly` release. 2. Compares its `published_at` timestamp to `lastSeenNightlyPublishedAt` in extension storage. 3. On first install, records the current publish time without showing a badge. -4. Shows **“Update available — nightly #123”** (run number) when a newer nightly has been published. +4. Shows **“Update available — nightly (YYYY-MM-DD)”** when a newer nightly has been published. Checks are throttled to once every **6 hours** per browser profile (`localStorage` key `bsplus_lastGhReleaseCheck`). Recent results are cached in memory for the session. 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.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/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/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.svelte b/src/interface/pages/settings.svelte index dc26cf5a..50b64851 100644 --- a/src/interface/pages/settings.svelte +++ b/src/interface/pages/settings.svelte @@ -22,6 +22,7 @@ import { checkGithubReleaseUpdate, dismissNightlyUpdate, + getInstalledGhReleaseChannelLabel, isGhReleaseUpdateCheckEnabled, type GhReleaseUpdateInfo, } from "@/utils/githubReleaseUpdate"; @@ -33,6 +34,7 @@ let disclaimerTitle = $state("Confirm"); let disclaimerMessage = $state(""); const ghReleaseUpdateEnabled = isGhReleaseUpdateCheckEnabled(); + const ghReleaseChannelLabel = getInstalledGhReleaseChannelLabel(); let ghReleaseUpdate = $state(null); const openGhRelease = () => { @@ -172,7 +174,11 @@ {/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/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}