mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-15 08:04:12 +00:00
all settings sync
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+19
-69
@@ -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<string, MessageHandler> = {
|
||||
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();
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
right: 10px;
|
||||
top: 80px;
|
||||
height: 590px;
|
||||
z-index: 20;
|
||||
z-index: 100;
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -203,7 +209,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
{shortcut.name}
|
||||
<button aria-label="Delete Shortcut" onclick={() => deleteCustomShortcut(index)}>
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
|
||||
<article class="bsplus-analytics-card">
|
||||
|
||||
<header class="bsplus-analytics-card-header">
|
||||
<header class="bsplus-analytics-card-header bsplus-analytics-card-header-split">
|
||||
|
||||
<div>
|
||||
|
||||
@@ -178,6 +178,12 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div class="bsplus-analytics-card-controls" aria-hidden="true">
|
||||
|
||||
<div class="bsplus-analytics-card-control bsplus-analytics-card-control-spacer"></div>
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
|
||||
@@ -346,31 +352,33 @@
|
||||
|
||||
<footer class="bsplus-analytics-card-footer">
|
||||
|
||||
{#if trend().direction === "up"}
|
||||
<p>
|
||||
|
||||
<span class="bsplus-analytics-trend-up"
|
||||
{#if trend().direction === "up"}
|
||||
|
||||
>Trending up · {trend().percentage}% vs previous period</span
|
||||
<span class="bsplus-analytics-trend-up"
|
||||
|
||||
>
|
||||
>Trending up · {trend().percentage}% vs previous period</span
|
||||
|
||||
{:else if trend().direction === "down"}
|
||||
>
|
||||
|
||||
<span class="bsplus-analytics-trend-down"
|
||||
{:else if trend().direction === "down"}
|
||||
|
||||
>Trending down · {trend().percentage}% vs previous period</span
|
||||
<span class="bsplus-analytics-trend-down"
|
||||
|
||||
>
|
||||
>Trending down · {trend().percentage}% vs previous period</span
|
||||
|
||||
{:else}
|
||||
>
|
||||
|
||||
<span>Grades remain stable across this period</span>
|
||||
{:else}
|
||||
|
||||
{/if}
|
||||
<span>Grades remain stable across this period</span>
|
||||
|
||||
<br />
|
||||
{/if}
|
||||
|
||||
<span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
||||
{filteredData().length} data points · {getTimeRangeLabel(timeRange)}
|
||||
|
||||
@@ -380,7 +388,7 @@
|
||||
|
||||
{/if}
|
||||
|
||||
</span>
|
||||
</p>
|
||||
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -356,12 +356,6 @@
|
||||
|
||||
</Chart.Container>
|
||||
|
||||
{#if distribution().modeUsed === "letter"}
|
||||
|
||||
<p class="bsplus-analytics-scale-hint">{distribution().scaleLabel}</p>
|
||||
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
|
||||
<div class="bsplus-analytics-card-empty">
|
||||
@@ -380,27 +374,37 @@
|
||||
|
||||
<footer class="bsplus-analytics-card-footer">
|
||||
|
||||
{#if distribution().averagePercent !== null}
|
||||
{#if distribution().modeUsed === "letter" && distribution().scaleLabel}
|
||||
|
||||
Average <strong>{distribution().averagePercent}%</strong>
|
||||
|
||||
{:else}
|
||||
|
||||
Average <strong>—</strong>
|
||||
<p class="bsplus-analytics-scale-hint">{distribution().scaleLabel}</p>
|
||||
|
||||
{/if}
|
||||
|
||||
across {totalAssessments} assessment{totalAssessments === 1 ? "" : "s"}
|
||||
<p>
|
||||
|
||||
{#if distributionMode === "auto" && distribution().modeUsed === "letter"}
|
||||
{#if distribution().averagePercent !== null}
|
||||
|
||||
<span class="bsplus-analytics-footer-muted"> · letter scale detected</span>
|
||||
Average <strong>{distribution().averagePercent}%</strong>
|
||||
|
||||
{:else if distributionMode !== "auto"}
|
||||
{:else}
|
||||
|
||||
<span class="bsplus-analytics-footer-muted"> · {modeOptionLabel} grouping</span>
|
||||
Average <strong>—</strong>
|
||||
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
across {totalAssessments} assessment{totalAssessments === 1 ? "" : "s"}
|
||||
|
||||
{#if distributionMode === "auto" && distribution().modeUsed === "letter"}
|
||||
|
||||
<span class="bsplus-analytics-footer-muted"> · letter scale detected</span>
|
||||
|
||||
{:else if distributionMode !== "auto"}
|
||||
|
||||
<span class="bsplus-analytics-footer-muted"> · {modeOptionLabel} grouping</span>
|
||||
|
||||
{/if}
|
||||
|
||||
</p>
|
||||
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
--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 */
|
||||
--bsplus-analytics-accent: var(--better-main, #007bff);
|
||||
--bsplus-analytics-chart-height: 300px;
|
||||
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
@@ -342,13 +343,12 @@
|
||||
backdrop-filter: var(--bsplus-theme-card-blur, none);
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
isolation: isolate;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.bsplus-analytics-toolbar-dropdown-field {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.bsplus-analytics-field {
|
||||
@@ -541,7 +541,7 @@
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 0.35rem);
|
||||
z-index: 100;
|
||||
z-index: 5;
|
||||
min-width: 14rem;
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
@@ -601,11 +601,19 @@
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-charts > .bsplus-analytics-animate > .bsplus-analytics-card {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@@ -613,11 +621,18 @@
|
||||
grid-template-columns: 1fr 1fr;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border-radius: var(
|
||||
--bsplus-theme-card-radius,
|
||||
var(--bsplus-analytics-radius)
|
||||
@@ -669,6 +684,12 @@
|
||||
min-width: 9.5rem;
|
||||
}
|
||||
|
||||
.bsplus-analytics-card-control-spacer {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.bsplus-analytics-select-compact {
|
||||
min-width: 9.5rem;
|
||||
min-height: 2.5rem;
|
||||
@@ -678,7 +699,7 @@
|
||||
}
|
||||
|
||||
.bsplus-analytics-scale-hint {
|
||||
margin: 0.65rem 0 0;
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
color: var(--bsplus-analytics-muted);
|
||||
@@ -705,15 +726,35 @@
|
||||
.bsplus-analytics-card-body {
|
||||
padding: 1rem 1.15rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bsplus-analytics-surface);
|
||||
}
|
||||
|
||||
.bsplus-analytics-charts .bsplus-analytics-card-body {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bsplus-analytics-card-footer {
|
||||
padding: 0.85rem 1.25rem 1.1rem;
|
||||
border-top: 1px solid var(--bsplus-analytics-border);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bsplus-analytics-muted);
|
||||
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 {
|
||||
@@ -721,10 +762,12 @@
|
||||
flex-direction: column;
|
||||
align-items: 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;
|
||||
gap: 0.35rem;
|
||||
color: var(--bsplus-analytics-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-card-empty strong {
|
||||
@@ -741,16 +784,12 @@
|
||||
color: var(--bsplus-analytics-muted);
|
||||
}
|
||||
|
||||
.bsplus-analytics-root .bsplus-chart-surface {
|
||||
height: 280px;
|
||||
min-height: 280px;
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.bsplus-analytics-root .bsplus-chart-surface,
|
||||
.bsplus-analytics-root .bsplus-chart-surface-bar {
|
||||
height: 320px;
|
||||
min-height: 320px;
|
||||
max-height: 320px;
|
||||
height: var(--bsplus-analytics-chart-height);
|
||||
min-height: var(--bsplus-analytics-chart-height);
|
||||
max-height: var(--bsplus-analytics-chart-height);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Bar chart: show axis spines and tick marks (area chart hides these) */
|
||||
|
||||
@@ -23,6 +23,25 @@ let darkModeObserver: MutationObserver | null = null;
|
||||
let themeStyleObserver: MutationObserver | null = null;
|
||||
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 = [
|
||||
"--better-main",
|
||||
"--better-pale",
|
||||
@@ -161,6 +180,8 @@ function teardown() {
|
||||
export function renderAnalyticsPage(container: HTMLElement) {
|
||||
teardown();
|
||||
|
||||
ensureAnalyticsStackingScope();
|
||||
|
||||
container.innerHTML = "";
|
||||
container.className = "bsplus-analytics-container";
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise<
|
||||
* Only applies migrations for keys present in the data; does not overwrite
|
||||
* 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 };
|
||||
|
||||
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 so uploads contain a full schema.
|
||||
* 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 saveTimeout: NodeJS.Timeout | null = null;
|
||||
private initialized = false;
|
||||
private bootstrapping = false;
|
||||
|
||||
private constructor() {
|
||||
this.data = {} as SettingsState;
|
||||
@@ -33,7 +34,8 @@ class StorageManager {
|
||||
// Only save if the reference actually changed
|
||||
if (oldValue !== value) {
|
||||
Reflect.set(target.data, prop, value);
|
||||
target.saveToStorage();
|
||||
void target.saveToStorage([prop as string]);
|
||||
target.notifySettingChange(prop as string, value, oldValue);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
@@ -68,8 +70,23 @@ class StorageManager {
|
||||
public static async initialize(): Promise<StorageManager & SettingsState> {
|
||||
const instance = StorageManager.getInstance();
|
||||
if (!instance.initialized) {
|
||||
await instance.loadFromStorage();
|
||||
instance.initialized = true;
|
||||
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();
|
||||
instance.initialized = true;
|
||||
} finally {
|
||||
instance.bootstrapping = false;
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -81,16 +98,24 @@ class StorageManager {
|
||||
const oldValue = this.data[key];
|
||||
if (oldValue !== value) {
|
||||
this.data[key] = value;
|
||||
this.saveToStorage();
|
||||
void this.saveToStorage([key as string]);
|
||||
this.notifySettingChange(key as string, value, oldValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify listeners
|
||||
const listeners = this.listeners.get(key as string);
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener(value, oldValue);
|
||||
}
|
||||
private notifySettingChange(
|
||||
key: string,
|
||||
newValue: unknown,
|
||||
oldValue: unknown,
|
||||
): void {
|
||||
if (this.bootstrapping) return;
|
||||
const listeners = this.listeners.get(key);
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener(newValue, oldValue);
|
||||
}
|
||||
}
|
||||
this.notifySubscribers();
|
||||
}
|
||||
|
||||
private async loadFromStorage(): Promise<void> {
|
||||
@@ -100,14 +125,30 @@ class StorageManager {
|
||||
});
|
||||
}
|
||||
|
||||
public async saveToStorage(): Promise<void> {
|
||||
public async saveToStorage(changedKeys?: string[]): Promise<void> {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = null;
|
||||
}
|
||||
// @ts-expect-error
|
||||
await browser.storage.local.set(this.data);
|
||||
this.notifySubscribers();
|
||||
const payload: Record<string, unknown> = {};
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private async removeFromStorage(key: string): Promise<void> {
|
||||
@@ -116,39 +157,46 @@ class StorageManager {
|
||||
|
||||
private initStorageListener(): void {
|
||||
browser.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName === "local") {
|
||||
const actualChanges: string[] = [];
|
||||
|
||||
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
|
||||
// Only process if value actually changed
|
||||
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
|
||||
if (newValue !== undefined) {
|
||||
(this.data as any)[key] = newValue;
|
||||
} else {
|
||||
delete (this.data as any)[key];
|
||||
}
|
||||
actualChanges.push(key);
|
||||
|
||||
// Notify specific listeners
|
||||
const listeners = this.listeners.get(key);
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener(newValue, oldValue);
|
||||
}
|
||||
}
|
||||
if (areaName !== "local") return;
|
||||
|
||||
const actualChanges: string[] = [];
|
||||
|
||||
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
|
||||
if (JSON.stringify(oldValue) === JSON.stringify(newValue)) continue;
|
||||
|
||||
if (newValue !== undefined) {
|
||||
(this.data as Record<string, unknown>)[key] = newValue;
|
||||
} else {
|
||||
delete (this.data as Record<string, unknown>)[key];
|
||||
}
|
||||
actualChanges.push(key);
|
||||
|
||||
if (this.bootstrapping) continue;
|
||||
|
||||
const listeners = this.listeners.get(key);
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener(newValue, oldValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Only notify global listeners if there were actual changes
|
||||
if (actualChanges.length > 0 && this.globalListeners.size > 0) {
|
||||
for (const listener of this.globalListeners) {
|
||||
for (const key of actualChanges) {
|
||||
const { oldValue, newValue } = changes[key];
|
||||
listener(newValue, oldValue, key);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!this.bootstrapping &&
|
||||
actualChanges.length > 0 &&
|
||||
this.globalListeners.size > 0
|
||||
) {
|
||||
for (const listener of this.globalListeners) {
|
||||
for (const key of actualChanges) {
|
||||
const { oldValue, newValue } = changes[key];
|
||||
listener(newValue, oldValue, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.bootstrapping && actualChanges.length > 0) {
|
||||
this.notifySubscribers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -62,19 +62,15 @@ export class StorageChangeHandler {
|
||||
browser.runtime.sendMessage({ type: "reloadTabs" });
|
||||
}
|
||||
|
||||
private handleCustomShortcutsChange(
|
||||
newValue: CustomShortcut[],
|
||||
oldValue: CustomShortcut[],
|
||||
) {
|
||||
if (!newValue || !oldValue) return;
|
||||
private handleCustomShortcutsChange(newValue: CustomShortcut[] | undefined) {
|
||||
if (!Array.isArray(newValue)) return;
|
||||
renderShortcuts();
|
||||
}
|
||||
|
||||
private handleShortcutsChange(
|
||||
newValue: { enabled: boolean; name: string }[],
|
||||
oldValue: { enabled: boolean; name: string }[],
|
||||
newValue: { enabled: boolean; name: string }[] | undefined,
|
||||
) {
|
||||
if (!newValue || !oldValue) return;
|
||||
if (!Array.isArray(newValue)) return;
|
||||
renderShortcuts();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user