mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-15 16:14:13 +00:00
Merge pull request #451 from StroepWafel/fixcloudsync
all settings sync
This commit is contained in:
@@ -11,7 +11,7 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: stable
|
default: stable
|
||||||
build_label:
|
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
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
release_repo:
|
release_repo:
|
||||||
@@ -59,9 +59,17 @@ runs:
|
|||||||
- name: Package zips
|
- name: Package zips
|
||||||
id: zip
|
id: zip
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
UPDATE_CHANNEL: ${{ inputs.update_channel }}
|
||||||
|
BUILD_LABEL: ${{ inputs.build_label }}
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
(cd dist/chrome && zip -r "../betterseqtaplus-${VERSION}-chrome.zip" .)
|
if [ "$UPDATE_CHANNEL" = "nightly" ] && [ -n "$BUILD_LABEL" ]; then
|
||||||
(cd dist/firefox && zip -r "../betterseqtaplus-${VERSION}-firefox.zip" .)
|
BASE="betterseqtaplus-nightly-${BUILD_LABEL}"
|
||||||
echo "chrome_zip=dist/betterseqtaplus-${VERSION}-chrome.zip" >> "$GITHUB_OUTPUT"
|
else
|
||||||
echo "firefox_zip=dist/betterseqtaplus-${VERSION}-firefox.zip" >> "$GITHUB_OUTPUT"
|
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"
|
||||||
|
|||||||
@@ -20,22 +20,29 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set build date
|
||||||
|
id: build_date
|
||||||
|
run: echo "date=$(date -u +'%Y-%m-%d')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build extension
|
- name: Build extension
|
||||||
id: build
|
id: build
|
||||||
uses: ./.github/actions/build-extension
|
uses: ./.github/actions/build-extension
|
||||||
with:
|
with:
|
||||||
gh_release_update_check: "true"
|
gh_release_update_check: "true"
|
||||||
update_channel: nightly
|
update_channel: nightly
|
||||||
build_label: ${{ github.run_number }}
|
build_label: ${{ steps.build_date.outputs.date }}
|
||||||
release_repo: ${{ github.repository }}
|
release_repo: ${{ github.repository }}
|
||||||
|
|
||||||
- name: Ensure nightly release exists
|
- name: Ensure nightly release exists
|
||||||
run: |
|
run: |
|
||||||
|
TITLE="Nightly (${{ steps.build_date.outputs.date }})"
|
||||||
if ! gh release view "${{ env.NIGHTLY_TAG }}" 2>/dev/null; then
|
if ! gh release view "${{ env.NIGHTLY_TAG }}" 2>/dev/null; then
|
||||||
gh release create "${{ env.NIGHTLY_TAG }}" \
|
gh release create "${{ env.NIGHTLY_TAG }}" \
|
||||||
--prerelease \
|
--prerelease \
|
||||||
--title "Nightly" \
|
--title "$TITLE" \
|
||||||
--notes-file .github/nightly-release-notes.md
|
--notes-file .github/nightly-release-notes.md
|
||||||
|
else
|
||||||
|
gh release edit "${{ env.NIGHTLY_TAG }}" --title "$TITLE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload nightly assets
|
- name: Upload nightly assets
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
ESLINT_USE_FLAT_CONFIG: "false"
|
ESLINT_USE_FLAT_CONFIG: "false"
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- name: Build extension
|
- name: Build extension
|
||||||
uses: ./.github/actions/build-extension
|
uses: ./.github/actions/build-extension
|
||||||
with:
|
with:
|
||||||
gh_release_update_check: "false"
|
gh_release_update_check: "false"
|
||||||
|
|
||||||
|
- name: Smoke tests
|
||||||
|
run: npm run test:smoke
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@ Use the same **access tokens** issued by the existing BetterSEQTA+ OAuth flows (
|
|||||||
|
|
||||||
### `PUT /api/bsplus/settings/sync`
|
### `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):**
|
**Request body (JSON):**
|
||||||
|
|
||||||
@@ -37,12 +37,14 @@ Upserts the caller’s settings backup.
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"themeId": "uuid-string-or-empty",
|
"themeId": "uuid-string-or-empty",
|
||||||
"data": {
|
"data": {
|
||||||
"...": "flat key-value map mirroring extension storage (see Payload shape)",
|
"DarkMode": true,
|
||||||
"selectedTheme": "uuid-or-empty-string"
|
"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.
|
- **`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`**.
|
- **`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`).
|
- **`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
|
```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.).
|
**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
|
## 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).
|
- **Optional later:** `If-Unmodified-Since` or a `revision` field for conflict detection (not required for v1).
|
||||||
|
|
||||||
## Security and privacy
|
## 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).
|
- **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.
|
- **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).
|
- **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).
|
- **`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.
|
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)
|
## 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.
|
- 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`.
|
- 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`.
|
||||||
|
|||||||
+4
-3
@@ -71,7 +71,7 @@ Release and nightly workflows pass environment variables into Vite, which bakes
|
|||||||
| `GH_RELEASE_UPDATE_CHECK` | `true` | `true` | `false` / unset |
|
| `GH_RELEASE_UPDATE_CHECK` | `true` | `true` | `false` / unset |
|
||||||
| `UPDATE_CHANNEL` | `stable` | `nightly` | `stable` (unused) |
|
| `UPDATE_CHANNEL` | `stable` | `nightly` | `stable` (unused) |
|
||||||
| `GH_RELEASE_REPO` | `BetterSEQTA/BetterSEQTA-Plus` | same | same |
|
| `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.
|
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).
|
1. Builds from the current `main` branch with the update detector enabled (nightly channel).
|
||||||
2. Uses a fixed release tag: **`nightly`** (marked as prerelease).
|
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).
|
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.
|
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.
|
1. Fetches the `nightly` release.
|
||||||
2. Compares its `published_at` timestamp to `lastSeenNightlyPublishedAt` in extension storage.
|
2. Compares its `published_at` timestamp to `lastSeenNightlyPublishedAt` in extension storage.
|
||||||
3. On first install, records the current publish time without showing a badge.
|
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.
|
Checks are throttled to once every **6 hours** per browser profile (`localStorage` key `bsplus_lastGhReleaseCheck`). Recent results are cached in memory for the session.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export default {
|
|||||||
transform: {
|
transform: {
|
||||||
'^.+\\.ts$': 'ts-jest',
|
'^.+\\.ts$': 'ts-jest',
|
||||||
},
|
},
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
'^webextension-polyfill$': '<rootDir>/src/test/mocks/webextension-polyfill.ts',
|
||||||
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'src/**/*.ts',
|
'src/**/*.ts',
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
"convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari",
|
"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",
|
"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}\"",
|
"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",
|
"release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes",
|
||||||
"publish": "bun lib/publish.js --b",
|
"publish": "bun lib/publish.js --b",
|
||||||
"zip": "bedframe zip"
|
"zip": "bedframe zip"
|
||||||
@@ -43,6 +45,7 @@
|
|||||||
"@crxjs/vite-plugin": "^2.4.0",
|
"@crxjs/vite-plugin": "^2.4.0",
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/d3-shape": "^3.1.8",
|
"@types/d3-shape": "^3.1.8",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
@@ -53,6 +56,7 @@
|
|||||||
"eslint": "^9.33.0",
|
"eslint": "^9.33.0",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"glob": "^11.0.1",
|
"glob": "^11.0.1",
|
||||||
|
"jest": "^30.4.2",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
@@ -61,6 +65,7 @@
|
|||||||
"sass-loader": "^16.0.5",
|
"sass-loader": "^16.0.5",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
"tailwindcss": "3",
|
"tailwindcss": "3",
|
||||||
|
"ts-jest": "^29.4.11",
|
||||||
"url": "^0.11.4"
|
"url": "^0.11.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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");
|
||||||
+19
-69
@@ -1,6 +1,8 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import type { SettingsState } from "@/types/storage";
|
import type { SettingsState } from "@/types/storage";
|
||||||
|
import { getDefaultSettingsState } from "@/seqta/utils/defaultSettings";
|
||||||
|
import { ensureSyncableStorageDefaults } from "@/seqta/utils/ensureSyncableStorageDefaults";
|
||||||
import { fetchNews } from "./background/news";
|
import { fetchNews } from "./background/news";
|
||||||
import {
|
import {
|
||||||
initCloudSettingsAutoSync,
|
initCloudSettingsAutoSync,
|
||||||
@@ -404,6 +406,15 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
|||||||
void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
|
void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
|
||||||
},
|
},
|
||||||
setDefaultStorage: () => SetStorageValue(getDefaultValues()),
|
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) => {
|
sendNews: (req, sendResponse) => {
|
||||||
fetchNews(req.source ?? "australia", sendResponse);
|
fetchNews(req.source ?? "australia", sendResponse);
|
||||||
return true;
|
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 {
|
function getDefaultValues(): SettingsState {
|
||||||
const isLowEndDevice = detectLowEndDevice();
|
return getDefaultSettingsState();
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SetStorageValue(object: any) {
|
function SetStorageValue(object: any) {
|
||||||
@@ -673,6 +616,8 @@ browser.runtime.onInstalled.addListener(function (event) {
|
|||||||
browser.storage.local.remove(["justupdated"]);
|
browser.storage.local.remove(["justupdated"]);
|
||||||
browser.storage.local.remove(["data"]);
|
browser.storage.local.remove(["data"]);
|
||||||
|
|
||||||
|
void ensureSyncableStorageDefaults();
|
||||||
|
|
||||||
if (event.reason == "install" || event.reason == "update") {
|
if (event.reason == "install" || event.reason == "update") {
|
||||||
browser.storage.local.set({ justupdated: true });
|
browser.storage.local.set({ justupdated: true });
|
||||||
}
|
}
|
||||||
@@ -684,4 +629,9 @@ browser.runtime.onInstalled.addListener(function (event) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
browser.runtime.onStartup.addListener(() => {
|
||||||
|
void ensureSyncableStorageDefaults();
|
||||||
|
});
|
||||||
|
|
||||||
initCloudSettingsAutoSync({ reloadSeqtaPages });
|
initCloudSettingsAutoSync({ reloadSeqtaPages });
|
||||||
|
void ensureSyncableStorageDefaults();
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
import {
|
||||||
|
ensureSyncableStorageDefaults,
|
||||||
|
getSyncableStorageDefaults,
|
||||||
|
} from "@/seqta/utils/ensureSyncableStorageDefaults";
|
||||||
import {
|
import {
|
||||||
applyDownloadedEnvelope,
|
applyDownloadedEnvelope,
|
||||||
buildUploadPayload,
|
|
||||||
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
|
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
|
||||||
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
|
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
|
||||||
|
buildUploadPatch,
|
||||||
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
|
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
|
||||||
isKeyIncludedInCloudUploadPayload,
|
isKeyIncludedInCloudUploadPayload,
|
||||||
|
normalizeStorageForSync,
|
||||||
resolveThemeIdForPostSyncDownload,
|
resolveThemeIdForPostSyncDownload,
|
||||||
|
saveLastUploadedSnapshot,
|
||||||
|
getLastUploadedSnapshot,
|
||||||
setKnownRemoteUpdatedAt,
|
setKnownRemoteUpdatedAt,
|
||||||
} from "@/seqta/utils/cloudSettingsSync";
|
} from "@/seqta/utils/cloudSettingsSync";
|
||||||
|
|
||||||
@@ -138,13 +145,38 @@ async function fetchCloudSummaryWithAuthRetry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PutResult =
|
type PutResult =
|
||||||
| { ok: true; updated_at?: string }
|
| { ok: true; updated_at?: string; skipped?: boolean }
|
||||||
| { ok: false; unauthorized: boolean; error?: string };
|
| { ok: false; unauthorized: boolean; error?: string };
|
||||||
|
|
||||||
|
async function resolveUploadBaseline(
|
||||||
|
normalized: Record<string, unknown>,
|
||||||
|
watermark: string | undefined,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const lastSnapshot = await getLastUploadedSnapshot();
|
||||||
|
if (lastSnapshot) return lastSnapshot;
|
||||||
|
|
||||||
|
if (watermark) {
|
||||||
|
await saveLastUploadedSnapshot(normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSyncableStorageDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
async function putSettingsOnce(token: string): Promise<PutResult> {
|
async function putSettingsOnce(token: string): Promise<PutResult> {
|
||||||
try {
|
try {
|
||||||
const all = await browser.storage.local.get();
|
await ensureSyncableStorageDefaults();
|
||||||
const payload = buildUploadPayload(all as Record<string, unknown>);
|
|
||||||
|
const all = (await browser.storage.local.get()) as Record<string, unknown>;
|
||||||
|
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, {
|
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -163,6 +195,7 @@ async function putSettingsOnce(token: string): Promise<PutResult> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const updated_at = data?.updated_at as string | undefined;
|
const updated_at = data?.updated_at as string | undefined;
|
||||||
|
await saveLastUploadedSnapshot(normalized);
|
||||||
await setKnownRemoteUpdatedAt(updated_at);
|
await setKnownRemoteUpdatedAt(updated_at);
|
||||||
return { ok: true, updated_at };
|
return { ok: true, updated_at };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -176,11 +209,13 @@ async function putSettingsOnce(token: string): Promise<PutResult> {
|
|||||||
|
|
||||||
export async function performCloudSettingsUploadWithRetry(
|
export async function performCloudSettingsUploadWithRetry(
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<{ success: boolean; error?: string; updated_at?: string }> {
|
): Promise<{ success: boolean; error?: string; updated_at?: string; skipped?: boolean }> {
|
||||||
let t = token;
|
let t = token;
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
const res = await putSettingsOnce(t);
|
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) {
|
if (res.unauthorized && attempt === 0) {
|
||||||
const refreshed = await tryRefreshTokens();
|
const refreshed = await tryRefreshTokens();
|
||||||
if (!refreshed) return { success: false, error: "Not authenticated" };
|
if (!refreshed) return { success: false, error: "Not authenticated" };
|
||||||
@@ -234,6 +269,9 @@ async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
|
|||||||
reloadSeqtaPagesFn?.();
|
reloadSeqtaPagesFn?.();
|
||||||
const updated_at = data?.updated_at as string | undefined;
|
const updated_at = data?.updated_at as string | undefined;
|
||||||
await setKnownRemoteUpdatedAt(updated_at);
|
await setKnownRemoteUpdatedAt(updated_at);
|
||||||
|
await saveLastUploadedSnapshot(
|
||||||
|
normalizeStorageForSync((await browser.storage.local.get()) as Record<string, unknown>),
|
||||||
|
);
|
||||||
return { ok: true, updated_at };
|
return { ok: true, updated_at };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
right: 10px;
|
right: 10px;
|
||||||
top: 80px;
|
top: 80px;
|
||||||
height: 590px;
|
height: 590px;
|
||||||
z-index: 20;
|
z-index: 100;
|
||||||
transition-duration: 100ms;
|
transition-duration: 100ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import {
|
import {
|
||||||
checkGithubReleaseUpdate,
|
checkGithubReleaseUpdate,
|
||||||
dismissNightlyUpdate,
|
dismissNightlyUpdate,
|
||||||
|
getInstalledGhReleaseChannelLabel,
|
||||||
isGhReleaseUpdateCheckEnabled,
|
isGhReleaseUpdateCheckEnabled,
|
||||||
type GhReleaseUpdateInfo,
|
type GhReleaseUpdateInfo,
|
||||||
} from "@/utils/githubReleaseUpdate";
|
} from "@/utils/githubReleaseUpdate";
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
let disclaimerTitle = $state("Confirm");
|
let disclaimerTitle = $state("Confirm");
|
||||||
let disclaimerMessage = $state("");
|
let disclaimerMessage = $state("");
|
||||||
const ghReleaseUpdateEnabled = isGhReleaseUpdateCheckEnabled();
|
const ghReleaseUpdateEnabled = isGhReleaseUpdateCheckEnabled();
|
||||||
|
const ghReleaseChannelLabel = getInstalledGhReleaseChannelLabel();
|
||||||
let ghReleaseUpdate = $state<GhReleaseUpdateInfo | null>(null);
|
let ghReleaseUpdate = $state<GhReleaseUpdateInfo | null>(null);
|
||||||
|
|
||||||
const openGhRelease = () => {
|
const openGhRelease = () => {
|
||||||
@@ -172,7 +174,11 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="text-[9px] leading-tight text-right text-zinc-500 dark:text-zinc-400">
|
<p class="text-[9px] leading-tight text-right text-zinc-500 dark:text-zinc-400">
|
||||||
|
{#if ghReleaseChannelLabel}
|
||||||
|
{ghReleaseChannelLabel} — do not upload to extension stores.
|
||||||
|
{:else}
|
||||||
GitHub release build — do not upload to extension stores.
|
GitHub release build — do not upload to extension stores.
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -24,18 +24,18 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const switchChange = (shortcut: any) => {
|
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) {
|
if (idx !== -1) {
|
||||||
// Create a new array with the toggled value to ensure reactivity
|
const updated = current.map(s =>
|
||||||
const updated = settingsState.shortcuts.map(s =>
|
|
||||||
s.name === shortcut ? { ...s, enabled: !s.enabled } : s
|
s.name === shortcut ? { ...s, enabled: !s.enabled } : s
|
||||||
);
|
);
|
||||||
settingsState.shortcuts = updated;
|
settingsState.setKey("shortcuts", updated);
|
||||||
} else {
|
} else {
|
||||||
settingsState.shortcuts = [
|
settingsState.setKey("shortcuts", [
|
||||||
...settingsState.shortcuts,
|
...current,
|
||||||
{ name: shortcut, enabled: true }
|
{ name: shortcut, enabled: true },
|
||||||
];
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +82,10 @@
|
|||||||
if (isValidTitle(newTitle) && isValidURL(newURL)) {
|
if (isValidTitle(newTitle) && isValidURL(newURL)) {
|
||||||
const icon = newIcon || newTitle[0];
|
const icon = newIcon || newTitle[0];
|
||||||
const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon };
|
const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon };
|
||||||
settingsState.customshortcuts = [...settingsState.customshortcuts, newShortcut];
|
settingsState.setKey("customshortcuts", [
|
||||||
|
...(settingsState.customshortcuts ?? []),
|
||||||
|
newShortcut,
|
||||||
|
]);
|
||||||
|
|
||||||
newTitle = "";
|
newTitle = "";
|
||||||
newURL = "";
|
newURL = "";
|
||||||
@@ -94,7 +97,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteCustomShortcut = (index: number) => {
|
const deleteCustomShortcut = (index: number) => {
|
||||||
settingsState.customshortcuts = settingsState.customshortcuts.filter((_, i) => i !== index);
|
settingsState.setKey(
|
||||||
|
"customshortcuts",
|
||||||
|
(settingsState.customshortcuts ?? []).filter((_, i) => i !== index),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -203,7 +209,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Shortcuts Section -->
|
<!-- Custom Shortcuts Section -->
|
||||||
{#each $settingsState.customshortcuts as shortcut, index}
|
{#each ($settingsState.customshortcuts ?? []) as shortcut, index}
|
||||||
<div class="flex justify-between items-center px-4 py-3">
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
{shortcut.name}
|
{shortcut.name}
|
||||||
<button aria-label="Delete Shortcut" onclick={() => deleteCustomShortcut(index)}>
|
<button aria-label="Delete Shortcut" onclick={() => deleteCustomShortcut(index)}>
|
||||||
|
|||||||
@@ -356,12 +356,6 @@
|
|||||||
|
|
||||||
</Chart.Container>
|
</Chart.Container>
|
||||||
|
|
||||||
{#if distribution().modeUsed === "letter"}
|
|
||||||
|
|
||||||
<p class="bsplus-analytics-scale-hint">{distribution().scaleLabel}</p>
|
|
||||||
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
|
|
||||||
<div class="bsplus-analytics-card-empty">
|
<div class="bsplus-analytics-card-empty">
|
||||||
@@ -380,6 +374,14 @@
|
|||||||
|
|
||||||
<footer class="bsplus-analytics-card-footer">
|
<footer class="bsplus-analytics-card-footer">
|
||||||
|
|
||||||
|
{#if distribution().modeUsed === "letter" && distribution().scaleLabel}
|
||||||
|
|
||||||
|
<p class="bsplus-analytics-scale-hint">{distribution().scaleLabel}</p>
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
|
||||||
{#if distribution().averagePercent !== null}
|
{#if distribution().averagePercent !== null}
|
||||||
|
|
||||||
Average <strong>{distribution().averagePercent}%</strong>
|
Average <strong>{distribution().averagePercent}%</strong>
|
||||||
@@ -402,6 +404,8 @@
|
|||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
--bsplus-analytics-shadow-hover: 0 8px 24px 8px rgba(0, 0, 0, 0.16);
|
--bsplus-analytics-shadow-hover: 0 8px 24px 8px rgba(0, 0, 0, 0.16);
|
||||||
/* Set on host via ui.ts from --better-main / user selectedColor */
|
/* Set on host via ui.ts from --better-main / user selectedColor */
|
||||||
--bsplus-analytics-accent: var(--better-main, #007bff);
|
--bsplus-analytics-accent: var(--better-main, #007bff);
|
||||||
|
--bsplus-analytics-chart-height: 300px;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -340,8 +341,7 @@
|
|||||||
backdrop-filter: var(--bsplus-theme-card-blur, none);
|
backdrop-filter: var(--bsplus-theme-card-blur, none);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 40;
|
z-index: 3;
|
||||||
isolation: isolate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bsplus-analytics-toolbar-grid {
|
.bsplus-analytics-toolbar-grid {
|
||||||
@@ -413,7 +413,7 @@
|
|||||||
|
|
||||||
.bsplus-analytics-toolbar-dropdown-field {
|
.bsplus-analytics-toolbar-dropdown-field {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bsplus-analytics-field {
|
.bsplus-analytics-field {
|
||||||
@@ -589,7 +589,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: calc(100% + 0.35rem);
|
top: calc(100% + 0.35rem);
|
||||||
z-index: 100;
|
z-index: 5;
|
||||||
min-width: 14rem;
|
min-width: 14rem;
|
||||||
max-height: 12rem;
|
max-height: 12rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -675,8 +675,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Fade-in animation must not paint above the filter toolbar / dropdown */
|
/* Fade-in animation must not paint above the filter toolbar / dropdown */
|
||||||
.bsplus-analytics-charts .bsplus-analytics-animate {
|
.bsplus-analytics-charts > .bsplus-analytics-animate {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-analytics-charts > .bsplus-analytics-animate > .bsplus-analytics-card {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
@@ -684,6 +691,12 @@
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bsplus-analytics-charts .bsplus-analytics-card-header {
|
||||||
|
min-height: 5.75rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bsplus-analytics-card {
|
.bsplus-analytics-card {
|
||||||
@@ -769,6 +782,12 @@
|
|||||||
min-width: 9.5rem;
|
min-width: 9.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bsplus-analytics-card-control-spacer {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.bsplus-analytics-select-compact {
|
.bsplus-analytics-select-compact {
|
||||||
min-width: 9.5rem;
|
min-width: 9.5rem;
|
||||||
min-height: 2.5rem;
|
min-height: 2.5rem;
|
||||||
@@ -778,7 +797,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bsplus-analytics-scale-hint {
|
.bsplus-analytics-scale-hint {
|
||||||
margin: 0.65rem 0 0;
|
margin: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: var(--bsplus-analytics-muted);
|
color: var(--bsplus-analytics-muted);
|
||||||
@@ -805,17 +824,37 @@
|
|||||||
.bsplus-analytics-card-body {
|
.bsplus-analytics-card-body {
|
||||||
padding: 1rem 1.15rem;
|
padding: 1rem 1.15rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: var(--bsplus-analytics-surface);
|
background: var(--bsplus-analytics-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bsplus-analytics-charts .bsplus-analytics-card-body {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.bsplus-analytics-card-footer {
|
.bsplus-analytics-card-footer {
|
||||||
padding: 0.85rem 1.25rem 1.1rem;
|
padding: 0.85rem 1.25rem 1.1rem;
|
||||||
border-top: 1px solid var(--bsplus-analytics-border);
|
border-top: 1px solid var(--bsplus-analytics-border);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--bsplus-analytics-muted);
|
color: var(--bsplus-analytics-muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-analytics-charts .bsplus-analytics-card-footer {
|
||||||
|
min-height: 4.25rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-analytics-card-footer p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-analytics-card-footer p + p {
|
||||||
|
margin-top: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bsplus-analytics-card-empty {
|
.bsplus-analytics-card-empty {
|
||||||
@@ -823,10 +862,12 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 220px;
|
min-height: var(--bsplus-analytics-chart-height, 300px);
|
||||||
|
height: var(--bsplus-analytics-chart-height, 300px);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
color: var(--bsplus-analytics-muted);
|
color: var(--bsplus-analytics-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bsplus-analytics-card-empty strong {
|
.bsplus-analytics-card-empty strong {
|
||||||
@@ -846,9 +887,10 @@
|
|||||||
.bsplus-analytics-root .bsplus-chart-surface {
|
.bsplus-analytics-root .bsplus-chart-surface {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 280px;
|
height: var(--bsplus-analytics-chart-height, 280px);
|
||||||
min-height: 280px;
|
min-height: var(--bsplus-analytics-chart-height, 280px);
|
||||||
max-height: 280px;
|
max-height: var(--bsplus-analytics-chart-height, 280px);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bsplus-analytics-root .bsplus-chart-surface-bar {
|
.bsplus-analytics-root .bsplus-chart-surface-bar {
|
||||||
@@ -857,6 +899,7 @@
|
|||||||
height: 320px;
|
height: 320px;
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bar chart: show axis spines and tick marks (area chart hides these) */
|
/* Bar chart: show axis spines and tick marks (area chart hides these) */
|
||||||
|
|||||||
@@ -24,6 +24,25 @@ let darkModeObserver: MutationObserver | null = null;
|
|||||||
let themeStyleObserver: MutationObserver | null = null;
|
let themeStyleObserver: MutationObserver | null = null;
|
||||||
let themeListeners: ThemeListenerRegistration[] = [];
|
let themeListeners: ThemeListenerRegistration[] = [];
|
||||||
|
|
||||||
|
const ANALYTICS_STACKING_STYLE_ID = "bsplus-analytics-stacking-styles";
|
||||||
|
|
||||||
|
/** Light-DOM stacking scope so toolbar/dropdown z-index cannot paint over ExtensionPopup. */
|
||||||
|
function ensureAnalyticsStackingScope() {
|
||||||
|
if (document.getElementById(ANALYTICS_STACKING_STYLE_ID)) return;
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = ANALYTICS_STACKING_STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
#analytics-view-container,
|
||||||
|
.bsplus-analytics-container,
|
||||||
|
.bsplus-analytics-host {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
const THEME_CSS_VARS = [
|
const THEME_CSS_VARS = [
|
||||||
"--better-main",
|
"--better-main",
|
||||||
"--better-pale",
|
"--better-pale",
|
||||||
@@ -175,6 +194,8 @@ function teardown() {
|
|||||||
export function renderAnalyticsPage(container: HTMLElement) {
|
export function renderAnalyticsPage(container: HTMLElement) {
|
||||||
teardown();
|
teardown();
|
||||||
|
|
||||||
|
ensureAnalyticsStackingScope();
|
||||||
|
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
container.className = "bsplus-analytics-container";
|
container.className = "bsplus-analytics-container";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
|
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
|
||||||
|
import { clearLastUploadedSnapshot } from "@/seqta/utils/cloudSettingsSync";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
|
||||||
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||||
@@ -205,6 +206,7 @@ class CloudAuthService {
|
|||||||
public async logout(): Promise<void> {
|
public async logout(): Promise<void> {
|
||||||
const userId = this._state.user?.id;
|
const userId = this._state.user?.id;
|
||||||
if (userId) await clearCloudPfpCache(userId);
|
if (userId) await clearCloudPfpCache(userId);
|
||||||
|
await clearLastUploadedSnapshot();
|
||||||
await browser.storage.local.remove([
|
await browser.storage.local.remove([
|
||||||
STORAGE_KEYS.accessToken,
|
STORAGE_KEYS.accessToken,
|
||||||
STORAGE_KEYS.refreshToken,
|
STORAGE_KEYS.refreshToken,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
|
|
||||||
/** Matches the contract in docs/CLOUD_SETTINGS_SYNC_SERVER.md */
|
/** Matches the contract in docs/CLOUD_SETTINGS_SYNC_SERVER.md */
|
||||||
export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1;
|
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 =
|
export const BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY =
|
||||||
"bsplus_pending_theme_ensure_after_cloud";
|
"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).
|
* Never uploaded to the cloud backup (OAuth and legacy keys).
|
||||||
* IndexedDB (e.g. Global Search’s `betterseqta-index` database) is not part of
|
* 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 = [
|
const CLIENT_ONLY_CLOUD_KEYS_EXACT = [
|
||||||
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
|
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
|
||||||
|
BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY,
|
||||||
"bsplus_lastCloudPoll",
|
"bsplus_lastCloudPoll",
|
||||||
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
|
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
|
||||||
] as const;
|
] as const;
|
||||||
@@ -118,30 +127,79 @@ export function normalizeThemeIdForSync(raw: unknown): string {
|
|||||||
return raw.trim();
|
return raw.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildUploadPayload(all: Record<string, unknown>): {
|
/** Filter omit lists and migrate legacy keys → full syncable map for diff/export. */
|
||||||
schemaVersion: number;
|
export function normalizeStorageForSync(all: Record<string, unknown>): Record<string, unknown> {
|
||||||
themeId: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
} {
|
|
||||||
const filtered: Record<string, unknown> = {};
|
const filtered: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(all)) {
|
for (const [k, v] of Object.entries(all)) {
|
||||||
if (shouldOmitKeyFromCloudPayload(k)) continue;
|
if (shouldOmitKeyFromCloudPayload(k)) continue;
|
||||||
filtered[k] = v;
|
filtered[k] = v;
|
||||||
}
|
}
|
||||||
const data = migrateLegacyToPluginSettings(filtered);
|
return migrateLegacyToPluginSettings(filtered);
|
||||||
const themeId = normalizeThemeIdForSync(all.selectedTheme);
|
}
|
||||||
|
|
||||||
|
/** Keys in `current` whose values differ from `baseline` (sparse PUT body). */
|
||||||
|
export function diffSyncableStorage(
|
||||||
|
current: Record<string, unknown>,
|
||||||
|
baseline: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sparse upload envelope, or null when nothing changed vs baseline. */
|
||||||
|
export function buildUploadPatch(
|
||||||
|
all: Record<string, unknown>,
|
||||||
|
baseline: Record<string, unknown>,
|
||||||
|
): CloudSettingsUploadEnvelope | null {
|
||||||
|
const normalized = normalizeStorageForSync(all);
|
||||||
|
const data = diffSyncableStorage(normalized, baseline);
|
||||||
|
if (Object.keys(data).length === 0) return null;
|
||||||
return {
|
return {
|
||||||
schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
|
schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
|
||||||
themeId,
|
themeId: normalizeThemeIdForSync(all.selectedTheme),
|
||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSnapshotForUpload(): Promise<{
|
/** Full normalized snapshot (dev export / debugging). */
|
||||||
schemaVersion: number;
|
export function buildUploadPayload(all: Record<string, unknown>): CloudSettingsUploadEnvelope {
|
||||||
themeId: string;
|
const data = normalizeStorageForSync(all);
|
||||||
data: Record<string, unknown>;
|
return {
|
||||||
}> {
|
schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
|
||||||
|
themeId: normalizeThemeIdForSync(all.selectedTheme),
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLastUploadedSnapshot(): Promise<Record<string, unknown> | 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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLastUploadedSnapshot(
|
||||||
|
snapshot: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
await browser.storage.local.set({ [BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY]: snapshot });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearLastUploadedSnapshot(): Promise<void> {
|
||||||
|
await browser.storage.local.remove(BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSnapshotForUpload(): Promise<CloudSettingsUploadEnvelope> {
|
||||||
const all = await browser.storage.local.get();
|
const all = await browser.storage.local.get();
|
||||||
return buildUploadPayload(all as Record<string, unknown>);
|
return buildUploadPayload(all as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
@@ -190,7 +248,7 @@ export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise<
|
|||||||
* Only applies migrations for keys present in the data; does not overwrite
|
* Only applies migrations for keys present in the data; does not overwrite
|
||||||
* existing plugin settings if the legacy key is absent.
|
* existing plugin settings if the legacy key is absent.
|
||||||
*/
|
*/
|
||||||
function migrateLegacyToPluginSettings(data: Record<string, unknown>): Record<string, unknown> {
|
export function migrateLegacyToPluginSettings(data: Record<string, unknown>): Record<string, unknown> {
|
||||||
const result = { ...data };
|
const result = { ...data };
|
||||||
|
|
||||||
function ensurePluginSettings(pluginId: string): Record<string, unknown> {
|
function ensurePluginSettings(pluginId: string): Record<string, unknown> {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { SettingsState } from "@/types/storage";
|
||||||
|
|
||||||
|
function detectLowEndDevice(): boolean {
|
||||||
|
const lowCoreCount =
|
||||||
|
navigator.hardwareConcurrency && navigator.hardwareConcurrency < 4;
|
||||||
|
const lowMemory =
|
||||||
|
(navigator as Navigator & { deviceMemory?: number }).deviceMemory != null &&
|
||||||
|
(navigator as Navigator & { deviceMemory?: number }).deviceMemory! <= 2;
|
||||||
|
|
||||||
|
return !!(lowCoreCount || lowMemory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default core settings for a fresh profile (`SettingsState` shape). */
|
||||||
|
export function getDefaultSettingsState(): 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,
|
||||||
|
notificationCollector: false,
|
||||||
|
newsSource: "australia",
|
||||||
|
iconOnlySidebar: false,
|
||||||
|
adaptiveThemeColour: false,
|
||||||
|
adaptiveThemeGradient: false,
|
||||||
|
adaptiveThemeColourTransition: true,
|
||||||
|
themeOfTheMonthDisabled: false,
|
||||||
|
autoCloudSettingsSync: true,
|
||||||
|
selectedFont: "rubik",
|
||||||
|
timeFormat: "24",
|
||||||
|
privacyStatementShown: false,
|
||||||
|
engageParentsAnnouncementShown: false,
|
||||||
|
bsCloudAutoSyncAnnouncementShown: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import browser from "webextension-polyfill";
|
||||||
|
import { getAllPluginSettings } from "@/plugins";
|
||||||
|
import { getDefaultSettingsState } from "@/seqta/utils/defaultSettings";
|
||||||
|
import {
|
||||||
|
isKeyIncludedInCloudUploadPayload,
|
||||||
|
migrateLegacyToPluginSettings,
|
||||||
|
} from "@/seqta/utils/cloudSettingsSync";
|
||||||
|
|
||||||
|
/** Legacy top-level keys — never backfill; use `migrateLegacyToPluginSettings` instead. */
|
||||||
|
const LEGACY_STORAGE_KEYS = [
|
||||||
|
"animatedbk",
|
||||||
|
"bksliderinput",
|
||||||
|
"assessmentsAverage",
|
||||||
|
"lettergrade",
|
||||||
|
"notificationCollector",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys where `undefined` in storage is intentional and must not be replaced by a
|
||||||
|
* default (differs from the value we would write).
|
||||||
|
*/
|
||||||
|
const OPTIONAL_UNSET_MEANS_DEFAULT_KEYS = [
|
||||||
|
"timeFormat",
|
||||||
|
"selectedFont",
|
||||||
|
"privacyStatementShown",
|
||||||
|
"privacyStatementLastUpdated",
|
||||||
|
"engageParentsAnnouncementShown",
|
||||||
|
"bsCloudAutoSyncAnnouncementShown",
|
||||||
|
"themeOfTheMonthDismissedMonth",
|
||||||
|
"themeOfTheMonthLastSeenId",
|
||||||
|
"justupdated",
|
||||||
|
"devMode",
|
||||||
|
"hideSensitiveContent",
|
||||||
|
"mockNotices",
|
||||||
|
"devGhReleaseVersionOverride",
|
||||||
|
"lastSeenNightlyPublishedAt",
|
||||||
|
"originalDarkMode",
|
||||||
|
"profile_picture_revision",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function buildDefaultPluginSettings(
|
||||||
|
plugin: ReturnType<typeof getAllPluginSettings>[number],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, setting] of Object.entries(plugin.settings)) {
|
||||||
|
const meta = setting as { type?: string; default?: unknown };
|
||||||
|
if (meta.type === "component" || meta.type === "button") continue;
|
||||||
|
out[key] = meta.default;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat default map in upload shape (plugin-format only; no legacy keys).
|
||||||
|
*/
|
||||||
|
export function getSyncableStorageDefaults(): Record<string, unknown> {
|
||||||
|
const flat: Record<string, unknown> = {
|
||||||
|
...getDefaultSettingsState(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of LEGACY_STORAGE_KEYS) {
|
||||||
|
delete flat[key];
|
||||||
|
}
|
||||||
|
for (const key of OPTIONAL_UNSET_MEANS_DEFAULT_KEYS) {
|
||||||
|
delete flat[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const plugin of getAllPluginSettings()) {
|
||||||
|
flat[`plugin.${plugin.pluginId}.settings`] =
|
||||||
|
buildDefaultPluginSettings(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePluginSettingsDefaults(
|
||||||
|
defaults: Record<string, unknown>,
|
||||||
|
fromLegacy: unknown,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!fromLegacy || typeof fromLegacy !== "object" || Array.isArray(fromLegacy)) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
return { ...defaults, ...(fromLegacy as Record<string, unknown>) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
const existing = await browser.storage.local.get();
|
||||||
|
const migratedFromExisting = migrateLegacyToPluginSettings({
|
||||||
|
...existing,
|
||||||
|
});
|
||||||
|
const defaults = getSyncableStorageDefaults();
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(defaults)) {
|
||||||
|
if (!isKeyIncludedInCloudUploadPayload(key)) continue;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(existing, key)) continue;
|
||||||
|
|
||||||
|
if (key.startsWith("plugin.") && key.endsWith(".settings")) {
|
||||||
|
patch[key] = mergePluginSettingsDefaults(
|
||||||
|
value as Record<string, unknown>,
|
||||||
|
migratedFromExisting[key],
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
patch[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(patch).length > 0) {
|
||||||
|
await browser.storage.local.set(patch);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ class StorageManager {
|
|||||||
private subscribers: Set<Subscriber<SettingsState>> = new Set();
|
private subscribers: Set<Subscriber<SettingsState>> = new Set();
|
||||||
private saveTimeout: NodeJS.Timeout | null = null;
|
private saveTimeout: NodeJS.Timeout | null = null;
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
|
private bootstrapping = false;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.data = {} as SettingsState;
|
this.data = {} as SettingsState;
|
||||||
@@ -33,7 +34,8 @@ class StorageManager {
|
|||||||
// Only save if the reference actually changed
|
// Only save if the reference actually changed
|
||||||
if (oldValue !== value) {
|
if (oldValue !== value) {
|
||||||
Reflect.set(target.data, prop, value);
|
Reflect.set(target.data, prop, value);
|
||||||
target.saveToStorage();
|
void target.saveToStorage([prop as string]);
|
||||||
|
target.notifySettingChange(prop as string, value, oldValue);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -68,8 +70,23 @@ class StorageManager {
|
|||||||
public static async initialize(): Promise<StorageManager & SettingsState> {
|
public static async initialize(): Promise<StorageManager & SettingsState> {
|
||||||
const instance = StorageManager.getInstance();
|
const instance = StorageManager.getInstance();
|
||||||
if (!instance.initialized) {
|
if (!instance.initialized) {
|
||||||
|
instance.bootstrapping = true;
|
||||||
|
try {
|
||||||
|
// Must run in the service worker — dynamic import() in content scripts
|
||||||
|
// resolves chunk URLs against the SEQTA page origin on Firefox.
|
||||||
|
try {
|
||||||
|
await browser.runtime.sendMessage({ type: "ensureStorageDefaults" });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
"[BetterSEQTA+] ensureStorageDefaults message failed:",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
await instance.loadFromStorage();
|
await instance.loadFromStorage();
|
||||||
instance.initialized = true;
|
instance.initialized = true;
|
||||||
|
} finally {
|
||||||
|
instance.bootstrapping = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
@@ -81,16 +98,24 @@ class StorageManager {
|
|||||||
const oldValue = this.data[key];
|
const oldValue = this.data[key];
|
||||||
if (oldValue !== value) {
|
if (oldValue !== value) {
|
||||||
this.data[key] = value;
|
this.data[key] = value;
|
||||||
this.saveToStorage();
|
void this.saveToStorage([key as string]);
|
||||||
|
this.notifySettingChange(key as string, value, oldValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notify listeners
|
private notifySettingChange(
|
||||||
const listeners = this.listeners.get(key as string);
|
key: string,
|
||||||
|
newValue: unknown,
|
||||||
|
oldValue: unknown,
|
||||||
|
): void {
|
||||||
|
if (this.bootstrapping) return;
|
||||||
|
const listeners = this.listeners.get(key);
|
||||||
if (listeners) {
|
if (listeners) {
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
listener(value, oldValue);
|
listener(newValue, oldValue);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.notifySubscribers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadFromStorage(): Promise<void> {
|
private async loadFromStorage(): Promise<void> {
|
||||||
@@ -100,15 +125,31 @@ class StorageManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async saveToStorage(): Promise<void> {
|
public async saveToStorage(changedKeys?: string[]): Promise<void> {
|
||||||
if (this.saveTimeout) {
|
if (this.saveTimeout) {
|
||||||
clearTimeout(this.saveTimeout);
|
clearTimeout(this.saveTimeout);
|
||||||
this.saveTimeout = null;
|
this.saveTimeout = null;
|
||||||
}
|
}
|
||||||
// @ts-expect-error
|
const payload: Record<string, unknown> = {};
|
||||||
await browser.storage.local.set(this.data);
|
const keys =
|
||||||
|
changedKeys && changedKeys.length > 0
|
||||||
|
? changedKeys
|
||||||
|
: Object.keys(this.data);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = (this.data as Record<string, unknown>)[key];
|
||||||
|
if (value !== undefined) {
|
||||||
|
payload[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(payload).length === 0) return;
|
||||||
|
|
||||||
|
await browser.storage.local.set(payload);
|
||||||
|
if (!this.bootstrapping) {
|
||||||
this.notifySubscribers();
|
this.notifySubscribers();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async removeFromStorage(key: string): Promise<void> {
|
private async removeFromStorage(key: string): Promise<void> {
|
||||||
await browser.storage.local.remove(key);
|
await browser.storage.local.remove(key);
|
||||||
@@ -116,20 +157,22 @@ class StorageManager {
|
|||||||
|
|
||||||
private initStorageListener(): void {
|
private initStorageListener(): void {
|
||||||
browser.storage.onChanged.addListener((changes, areaName) => {
|
browser.storage.onChanged.addListener((changes, areaName) => {
|
||||||
if (areaName === "local") {
|
if (areaName !== "local") return;
|
||||||
|
|
||||||
const actualChanges: string[] = [];
|
const actualChanges: string[] = [];
|
||||||
|
|
||||||
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
|
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
|
||||||
// Only process if value actually changed
|
if (JSON.stringify(oldValue) === JSON.stringify(newValue)) continue;
|
||||||
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
|
|
||||||
if (newValue !== undefined) {
|
if (newValue !== undefined) {
|
||||||
(this.data as any)[key] = newValue;
|
(this.data as Record<string, unknown>)[key] = newValue;
|
||||||
} else {
|
} else {
|
||||||
delete (this.data as any)[key];
|
delete (this.data as Record<string, unknown>)[key];
|
||||||
}
|
}
|
||||||
actualChanges.push(key);
|
actualChanges.push(key);
|
||||||
|
|
||||||
// Notify specific listeners
|
if (this.bootstrapping) continue;
|
||||||
|
|
||||||
const listeners = this.listeners.get(key);
|
const listeners = this.listeners.get(key);
|
||||||
if (listeners) {
|
if (listeners) {
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
@@ -137,10 +180,12 @@ class StorageManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Only notify global listeners if there were actual changes
|
if (
|
||||||
if (actualChanges.length > 0 && this.globalListeners.size > 0) {
|
!this.bootstrapping &&
|
||||||
|
actualChanges.length > 0 &&
|
||||||
|
this.globalListeners.size > 0
|
||||||
|
) {
|
||||||
for (const listener of this.globalListeners) {
|
for (const listener of this.globalListeners) {
|
||||||
for (const key of actualChanges) {
|
for (const key of actualChanges) {
|
||||||
const { oldValue, newValue } = changes[key];
|
const { oldValue, newValue } = changes[key];
|
||||||
@@ -148,6 +193,9 @@ class StorageManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.bootstrapping && actualChanges.length > 0) {
|
||||||
|
this.notifySubscribers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,19 +62,15 @@ export class StorageChangeHandler {
|
|||||||
browser.runtime.sendMessage({ type: "reloadTabs" });
|
browser.runtime.sendMessage({ type: "reloadTabs" });
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCustomShortcutsChange(
|
private handleCustomShortcutsChange(newValue: CustomShortcut[] | undefined) {
|
||||||
newValue: CustomShortcut[],
|
if (!Array.isArray(newValue)) return;
|
||||||
oldValue: CustomShortcut[],
|
|
||||||
) {
|
|
||||||
if (!newValue || !oldValue) return;
|
|
||||||
renderShortcuts();
|
renderShortcuts();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleShortcutsChange(
|
private handleShortcutsChange(
|
||||||
newValue: { enabled: boolean; name: string }[],
|
newValue: { enabled: boolean; name: string }[] | undefined,
|
||||||
oldValue: { enabled: boolean; name: string }[],
|
|
||||||
) {
|
) {
|
||||||
if (!newValue || !oldValue) return;
|
if (!Array.isArray(newValue)) return;
|
||||||
renderShortcuts();
|
renderShortcuts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const storage = new Map<string, unknown>();
|
||||||
|
|
||||||
|
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<string, unknown> = {};
|
||||||
|
for (const key of keys) {
|
||||||
|
if (storage.has(key)) out[key] = storage.get(key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}),
|
||||||
|
set: jest.fn(async (items: Record<string, unknown>) => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -35,6 +35,14 @@ function getBuildLabel(): string {
|
|||||||
return typeof __BUILD_LABEL__ !== "undefined" ? __BUILD_LABEL__ : "";
|
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 {
|
function getCurrentVersion(): string {
|
||||||
return browser.runtime.getManifest().version;
|
return browser.runtime.getManifest().version;
|
||||||
}
|
}
|
||||||
@@ -158,8 +166,7 @@ async function checkNightlyUpdate(): Promise<GhReleaseUpdateInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lastSeen = settingsState.lastSeenNightlyPublishedAt;
|
const lastSeen = settingsState.lastSeenNightlyPublishedAt;
|
||||||
const buildLabel = getBuildLabel();
|
const label = formatNightlyLabel(nightlyDateFromPublishedAt(release.published_at));
|
||||||
const label = buildLabel ? `nightly #${buildLabel}` : "nightly";
|
|
||||||
|
|
||||||
if (!lastSeen) {
|
if (!lastSeen) {
|
||||||
settingsState.lastSeenNightlyPublishedAt = release.published_at;
|
settingsState.lastSeenNightlyPublishedAt = release.published_at;
|
||||||
@@ -177,6 +184,12 @@ export function isGhReleaseUpdateCheckEnabled(): boolean {
|
|||||||
return isUpdateCheckEnabled();
|
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<GhReleaseUpdateInfo> {
|
export async function checkGithubReleaseUpdate(): Promise<GhReleaseUpdateInfo> {
|
||||||
const fallback = { available: false, label: "", url: releasesBaseUrl() };
|
const fallback = { available: false, label: "", url: releasesBaseUrl() };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user