feat: improve apis + add animated background and assessment average plugins

This commit is contained in:
SethBurkart123
2025-03-30 13:17:19 +11:00
parent aeaf5d9e59
commit b8d8b108c3
11 changed files with 189 additions and 268 deletions
@@ -195,26 +195,6 @@
onChange: (isOn: boolean) => settingsState.animations = isOn onChange: (isOn: boolean) => settingsState.animations = isOn
} }
}, },
{
title: "Assessment Average",
description: "Shows your subject average for assessments.",
id: 8,
Component: Switch,
props: {
state: $settingsState.assessmentsAverage,
onChange: (isOn: boolean) => settingsState.assessmentsAverage = isOn
}
},
{
title: "Letter Grade Averages",
description: "Shows the letter grade instead of the percentage in subject averages.",
id: 8,
Component: Switch,
props: {
state: $settingsState.lettergrade,
onChange: (isOn: boolean) => settingsState.lettergrade = isOn
}
},
{ {
title: "12 Hour Time", title: "12 Hour Time",
description: "Prefer 12 hour time format for SEQTA", description: "Prefer 12 hour time format for SEQTA",
@@ -0,0 +1,115 @@
import { BasePlugin } from "@/plugins/core/settings";
import { defineSettings, booleanSetting, Setting } from "@/plugins/core/settingsHelpers";
import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
const settings = defineSettings({
lettergrade: booleanSetting({
default: false,
title: "Letter Grades",
description: "Display the average as a letter instead of a percentage"
}),
});
class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
@Setting(settings.lettergrade)
lettergrade!: boolean;
}
const instance = new AssessmentsAveragePluginClass();
const assessmentsAveragePlugin: Plugin<typeof settings> = {
id: "assessments-average",
name: "Assessment Averages",
description: "Adds an average grade to the Assessments page",
version: "1.0.0",
disableToggle: true,
settings: instance.settings,
run: async (api) => {
api.seqta.onMount(".assessmentsWrapper", async () => {
await waitForElm(
"#main > .assessmentsWrapper .assessments .AssessmentItem__AssessmentItem___2EZ95",
true,
10,
1000
)
const assessmentsList = document.querySelector("#main > .assessmentsWrapper .assessments .AssessmentList__items___3LcmQ");
if (!assessmentsList) return;
const gradeElements = document.querySelectorAll(".Thermoscore__text___1NdvB");
if (!gradeElements.length) return;
// Parse and average grades
const letterToNumber: Record<string, number> = {
"A+": 100, A: 95, "A-": 90,
"B+": 85, B: 80, "B-": 75,
"C+": 70, C: 65, "C-": 60,
"D+": 55, D: 50, "D-": 45,
"E+": 40, E: 35, "E-": 30,
F: 0,
};
function parseGrade(text: string): number {
const str = text.trim().toUpperCase();
if (str.includes("/")) {
const [raw, max] = str.split("/").map(n => parseFloat(n));
return (raw / max) * 100;
}
if (str.includes("%")) {
return parseFloat(str.replace("%", "")) || 0;
}
return letterToNumber[str] ?? 0;
}
let total = 0;
let count = 0;
gradeElements.forEach((el) => {
const grade = parseGrade(el.textContent || "");
if (grade > 0) {
total += grade;
count++;
}
});
if (!count) return;
const avg = total / count;
const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce((acc, [k, v]) => {
acc[v] = k;
return acc;
}, {} as Record<number, string>);
const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
// Prevent duplicate
const existing = assessmentsList.querySelector(".AssessmentItem__title___2bELn");
if (existing?.textContent === "Subject Average") return;
const averageElement = stringToHTML(/* html */ `
<div class="AssessmentItem__AssessmentItem___2EZ95">
<div class="AssessmentItem__metaContainer___dMKma">
<div class="AssessmentItem__meta___WNSiK">
<div class="AssessmentItem__simpleResult___iBCeC">
<div class="AssessmentItem__title___2bELn">Subject Average</div>
</div>
</div>
</div>
<div class="Thermoscore__Thermoscore___2tWMi">
<div class="Thermoscore__fill___35WjF" style="width: ${avg.toFixed(2)}%">
<div class="Thermoscore__text___1NdvB" title="${display}">${display}</div>
</div>
</div>
</div>
`).firstChild;
assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild);
});
}
};
export default assessmentsAveragePlugin;
+58 -59
View File
@@ -42,58 +42,9 @@ function createSEQTAAPI(): SEQTAAPI {
function createSettingsAPI<T extends PluginSettings>(plugin: Plugin<T>): SettingsAPI<T> & { loaded: Promise<void> } { function createSettingsAPI<T extends PluginSettings>(plugin: Plugin<T>): SettingsAPI<T> & { loaded: Promise<void> } {
const storageKey = `plugin.${plugin.id}.settings`; const storageKey = `plugin.${plugin.id}.settings`;
const listeners = new Map<keyof T, Set<(value: any) => void>>(); const listeners = new Map<keyof T, Set<(value: any) => void>>();
let settings: { [K in keyof T]: SettingValue<T[K]> };
const storageListeners = new Set<(changes: { [key: string]: any }, area: string) => void>();
// Initialize settings with defaults // Initialize with default values
const defaultSettings = {} as { [K in keyof T]: SettingValue<T[K]> }; const settingsWithMeta: any = {
for (const key in plugin.settings) {
defaultSettings[key] = plugin.settings[key].default as SettingValue<T[typeof key]>;
}
settings = defaultSettings;
// Create a promise that resolves when settings are loaded
const loaded = (async () => {
try {
const stored = await browser.storage.local.get(storageKey);
if (stored[storageKey]) {
Object.entries(stored[storageKey]).forEach(([key, value]) => {
if (key in settings) {
settings[key as keyof T] = value as any;
// Notify any listeners that might have been registered already
listeners.get(key as keyof T)?.forEach(callback => callback(value));
}
});
}
} catch (error) {
console.error(`[BetterSEQTA+] Error loading settings for plugin ${plugin.id}:`, error);
}
})();
// Listen for storage changes
const handleStorageChange = (changes: { [key: string]: any }, area: string) => {
if (area === 'local' && changes[storageKey]) {
const newValue = changes[storageKey].newValue;
if (newValue) {
// Update settings and notify listeners
Object.entries(newValue).forEach(([key, value]) => {
settings[key as keyof T] = value as any;
listeners.get(key as keyof T)?.forEach(callback => callback(value));
});
}
}
};
browser.storage.onChanged.addListener(handleStorageChange);
storageListeners.add(handleStorageChange);
const baseSettings = {} as { [K in keyof T]: SettingValue<T[K]> };
for (const key in plugin.settings) {
baseSettings[key] = plugin.settings[key].default as SettingValue<T[typeof key]>;
}
const settingsWithMeta = {
...baseSettings,
onChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => { onChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => {
if (!listeners.has(key)) { if (!listeners.has(key)) {
listeners.set(key, new Set()); listeners.set(key, new Set());
@@ -108,23 +59,71 @@ function createSettingsAPI<T extends PluginSettings>(plugin: Plugin<T>): Setting
offChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => { offChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => {
listeners.get(key)?.delete(callback); listeners.get(key)?.delete(callback);
}, },
loaded loaded: Promise.resolve() // will be replaced below
}; };
// Fill with defaults first
for (const key in plugin.settings) {
settingsWithMeta[key] = plugin.settings[key].default;
}
// Load stored settings and override defaults
const loaded = (async () => {
try {
const stored = await browser.storage.local.get(storageKey);
const storedSettings = stored[storageKey] as Partial<Record<keyof T, any>>;
if (storedSettings) {
for (const key in storedSettings) {
if (key in settingsWithMeta) {
settingsWithMeta[key] = storedSettings[key];
listeners.get(key as keyof T)?.forEach(cb => cb(storedSettings[key]));
}
}
}
} catch (error) {
console.error(`[BetterSEQTA+] Error loading settings for plugin ${plugin.id}:`, error);
}
})();
settingsWithMeta.loaded = loaded;
// Listen for storage changes and update settingsWithMeta
const handleStorageChange = (changes: { [key: string]: browser.Storage.StorageChange }, area: string) => {
if (area !== 'local' || !(storageKey in changes)) return;
const newValue = changes[storageKey].newValue as Partial<Record<keyof T, any>> | undefined;
if (!newValue) return;
for (const key in newValue) {
const typedKey = key as keyof T;
settingsWithMeta[typedKey] = newValue[typedKey];
listeners.get(typedKey)?.forEach(cb => cb(newValue[typedKey]));
}
};
browser.storage.onChanged.addListener(handleStorageChange);
const proxy = new Proxy(settingsWithMeta, { const proxy = new Proxy(settingsWithMeta, {
get(target, prop) { get(target, prop) {
return target[prop as keyof typeof target]; return target[prop];
}, },
set(target, prop, value) { set(target, prop, value) {
if (prop === 'onChange' || prop === 'offChange' || prop === 'loaded') return false; if (['onChange', 'offChange', 'loaded'].includes(prop as string)) return false;
target[prop as keyof T] = value; target[prop] = value;
browser.storage.local.set({ [storageKey]: baseSettings }); // Only store base settings
listeners.get(prop as keyof T)?.forEach(callback => callback(value)); // Reconstruct just the data keys for storage (excluding metadata methods)
const dataToStore: any = {};
for (const key in plugin.settings) {
dataToStore[key] = target[key];
}
browser.storage.local.set({ [storageKey]: dataToStore });
listeners.get(prop as keyof T)?.forEach(cb => cb(value));
return true; return true;
} }
}) as SettingsAPI<T>; }) as SettingsAPI<T> & { loaded: Promise<void> };
return proxy; return proxy;
} }
+3 -2
View File
@@ -4,8 +4,8 @@ import { PluginManager } from './core/manager';
import timetablePlugin from './built-in/timetable'; import timetablePlugin from './built-in/timetable';
import notificationCollectorPlugin from './built-in/notificationCollector'; import notificationCollectorPlugin from './built-in/notificationCollector';
import themesPlugin from './built-in/themes'; import themesPlugin from './built-in/themes';
import animatedBackgroundPlugin from './built-in/animated-background'; import animatedBackgroundPlugin from './built-in/animatedBackground';
import assessmentsAveragePlugin from './built-in/assessmentsAverage';
// Initialize plugin manager // Initialize plugin manager
const pluginManager = PluginManager.getInstance(); const pluginManager = PluginManager.getInstance();
@@ -14,6 +14,7 @@ pluginManager.registerPlugin(timetablePlugin);
pluginManager.registerPlugin(notificationCollectorPlugin); pluginManager.registerPlugin(notificationCollectorPlugin);
pluginManager.registerPlugin(themesPlugin); pluginManager.registerPlugin(themesPlugin);
pluginManager.registerPlugin(animatedBackgroundPlugin); pluginManager.registerPlugin(animatedBackgroundPlugin);
pluginManager.registerPlugin(assessmentsAveragePlugin);
//pluginManager.registerPlugin(testPlugin); //pluginManager.registerPlugin(testPlugin);
export { init as Monofile } from './monofile'; export { init as Monofile } from './monofile';
-181
View File
@@ -256,17 +256,6 @@ async function LoadPageElements(): Promise<void> {
handleNotices, handleNotices,
) )
if (settingsState.assessmentsAverage) {
eventManager.register(
"assessmentsAdded",
{
elementType: "div",
className: "assessmentsWrapper",
},
handleAssessments,
)
}
RegisterClickListeners() RegisterClickListeners()
await handleSublink(sublink) await handleSublink(sublink)
@@ -666,173 +655,3 @@ export function AppendElementsToDisabledPage() {
` `
document.head.append(settingsStyle) document.head.append(settingsStyle)
} }
/*async function CheckForMenuList() {
try {
await waitForElm("#menu > ul")
ObserveMenuItemPosition()
} catch (error) {
return
}
}*/
async function handleAssessments(node: Element): Promise<void> {
if (!(node instanceof HTMLElement)) return
// Wait for the assessments wrapper to be mounted
const assessmentsWrapper = await waitForElm(
"#main > .assessmentsWrapper .assessments .AssessmentItem__AssessmentItem___2EZ95",
true,
50,
)
if (!assessmentsWrapper) return
// Grade conversion map for letter grades
const letterGradeMap: Record<string, number> = {
"A+": 100,
A: 95,
"A-": 90,
"B+": 85,
B: 80,
"B-": 75,
"C+": 70,
C: 65,
"C-": 60,
"D+": 55,
D: 50,
"D-": 45,
"E+": 40,
E: 35,
"E-": 30,
F: 0,
}
// Function to parse grade text into a number
function parseGrade(gradeText: string): number {
// Remove any whitespace
const trimmedGrade = gradeText.trim().toUpperCase()
// Check if it is a non-percent grade
if (trimmedGrade.includes("/")) {
const grade = trimmedGrade.split("/")
var a = grade[1] as unknown as number
var b = grade[0] as unknown as number
return (b / a) * 100
}
// Check if it's a percentage
if (trimmedGrade.includes("%")) {
return parseFloat(trimmedGrade.replace("%", "")) || 0
}
// Check if it's a letter grade
if (Object.prototype.hasOwnProperty.call(letterGradeMap, trimmedGrade)) {
return letterGradeMap[trimmedGrade]
}
return 0
}
// Function to calculate average of grades
function calculateAverageGrade(): number {
const gradeElements = document.querySelectorAll(
".Thermoscore__text___1NdvB",
)
let total = 0
let count = 0
gradeElements.forEach((element) => {
const gradeText = element.textContent || ""
const grade = parseGrade(gradeText)
if (grade > 0) {
total += grade
count++
}
})
return count > 0 ? total / count : 0
}
// Function to add the average assessment item
function addAverageAssessment() {
const numaverage = calculateAverageGrade()
if (numaverage === 0) return
// Remove existing average section if it exists
const existingAverage = document.querySelector(
".AssessmentItem__AssessmentItem___2EZ95:first-child",
)
if (
existingAverage?.querySelector(".AssessmentItem__title___2bELn")
?.textContent === "Subject Average"
) {
existingAverage.remove()
}
const preaverage = numaverage.toFixed(0) as unknown as number
const prepaverage = Math.ceil(preaverage / 5) * 5
const NumberGradeMap: Record<number, string> = {
100: "A+",
95: "A",
90: "A-",
85: "B+",
80: "B",
75: "B-",
70: "C+",
65: "C",
60: "C-",
55: "D+",
50: "D",
45: "D-",
40: "E+",
35: "E",
30: "E-",
0: "F",
}
var letteraverage = "N/A"
const check = Object.prototype.hasOwnProperty.call(
NumberGradeMap,
prepaverage,
)
if (check) {
console.debug("[BetterSEQTA+ Debugger] Match found")
letteraverage = NumberGradeMap[prepaverage]
} else {
console.debug("[BetterSEQTA+ Debugger] No match found")
letteraverage = "N/A"
}
var average = "N/A"
if (settingsState.lettergrade) {
average = letteraverage
} else {
average = `${numaverage.toFixed(2)}%`
}
const averageElement = stringToHTML(/* html */ `
<div class="AssessmentItem__AssessmentItem___2EZ95">
<div class="AssessmentItem__metaContainer___dMKma">
<div class="AssessmentItem__meta___WNSiK">
<div class="AssessmentItem__simpleResult___iBCeC">
<div class="AssessmentItem__title___2bELn">Subject Average</div>
</div>
</div>
</div>
<div class="Thermoscore__Thermoscore___2tWMi">
<div class="Thermoscore__fill___35WjF" style="width: ${numaverage.toFixed(2)}%">
<div class="Thermoscore__text___1NdvB" title="${average};">${average}</div>
</div>
</div>
</div>
`)
// Insert at the beginning of the assessments list
const assessmentsList = document.querySelector(
".assessments .AssessmentList__items___3LcmQ",
)
if (assessmentsList && averageElement.firstChild) {
assessmentsList.insertBefore(
averageElement.firstChild,
assessmentsList.firstChild,
)
}
}
// Add the average assessment item
addAverageAssessment()
}
-4
View File
@@ -21,10 +21,6 @@ export async function main() {
if (settingsState.onoff) { if (settingsState.onoff) {
injectPageState() injectPageState()
if (typeof settingsState.assessmentsAverage == "undefined") {
settingsState.assessmentsAverage = true
}
// TEMP FIX for bug! -> this is a hack to get the injected.css file to have HMR in development mode as this import system is currently broken with crxjs // TEMP FIX for bug! -> this is a hack to get the injected.css file to have HMR in development mode as this import system is currently broken with crxjs
if (import.meta.env.MODE === "development") { if (import.meta.env.MODE === "development") {
import("../css/injected.scss") import("../css/injected.scss")
+12 -1
View File
@@ -5,14 +5,25 @@ export async function waitForElm(
selector: string, selector: string,
usePolling: boolean = false, usePolling: boolean = false,
interval: number = 100, interval: number = 100,
maxIterations?: number
): Promise<Element> { ): Promise<Element> {
if (usePolling) { if (usePolling) {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
let iterations = 0;
if (maxIterations) {
iterations = 0;
}
const checkForElement = () => { const checkForElement = () => {
const element = document.querySelector(selector) const element = document.querySelector(selector)
if (element) { if (element) {
resolve(element) resolve(element)
} else { } else {
if (maxIterations) {
iterations++;
if (iterations >= maxIterations) {
reject(new Error("Element not found"));
}
}
setTimeout(checkForElement, interval) setTimeout(checkForElement, interval)
} }
} }