mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
feat: improve apis + add animated background and assessment average plugins
This commit is contained in:
@@ -195,26 +195,6 @@
|
||||
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",
|
||||
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;
|
||||
@@ -42,58 +42,9 @@ function createSEQTAAPI(): SEQTAAPI {
|
||||
function createSettingsAPI<T extends PluginSettings>(plugin: Plugin<T>): SettingsAPI<T> & { loaded: Promise<void> } {
|
||||
const storageKey = `plugin.${plugin.id}.settings`;
|
||||
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
|
||||
const defaultSettings = {} as { [K in keyof T]: SettingValue<T[K]> };
|
||||
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,
|
||||
// Initialize with default values
|
||||
const settingsWithMeta: any = {
|
||||
onChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => {
|
||||
if (!listeners.has(key)) {
|
||||
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) => {
|
||||
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, {
|
||||
get(target, prop) {
|
||||
return target[prop as keyof typeof target];
|
||||
return target[prop];
|
||||
},
|
||||
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;
|
||||
browser.storage.local.set({ [storageKey]: baseSettings }); // Only store base settings
|
||||
listeners.get(prop as keyof T)?.forEach(callback => callback(value));
|
||||
target[prop] = 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;
|
||||
}
|
||||
}) as SettingsAPI<T>;
|
||||
|
||||
}) as SettingsAPI<T> & { loaded: Promise<void> };
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { PluginManager } from './core/manager';
|
||||
import timetablePlugin from './built-in/timetable';
|
||||
import notificationCollectorPlugin from './built-in/notificationCollector';
|
||||
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
|
||||
const pluginManager = PluginManager.getInstance();
|
||||
|
||||
@@ -14,6 +14,7 @@ pluginManager.registerPlugin(timetablePlugin);
|
||||
pluginManager.registerPlugin(notificationCollectorPlugin);
|
||||
pluginManager.registerPlugin(themesPlugin);
|
||||
pluginManager.registerPlugin(animatedBackgroundPlugin);
|
||||
pluginManager.registerPlugin(assessmentsAveragePlugin);
|
||||
//pluginManager.registerPlugin(testPlugin);
|
||||
|
||||
export { init as Monofile } from './monofile';
|
||||
|
||||
@@ -256,17 +256,6 @@ async function LoadPageElements(): Promise<void> {
|
||||
handleNotices,
|
||||
)
|
||||
|
||||
if (settingsState.assessmentsAverage) {
|
||||
eventManager.register(
|
||||
"assessmentsAdded",
|
||||
{
|
||||
elementType: "div",
|
||||
className: "assessmentsWrapper",
|
||||
},
|
||||
handleAssessments,
|
||||
)
|
||||
}
|
||||
|
||||
RegisterClickListeners()
|
||||
|
||||
await handleSublink(sublink)
|
||||
@@ -666,173 +655,3 @@ export function AppendElementsToDisabledPage() {
|
||||
`
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -21,10 +21,6 @@ export async function main() {
|
||||
if (settingsState.onoff) {
|
||||
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
|
||||
if (import.meta.env.MODE === "development") {
|
||||
import("../css/injected.scss")
|
||||
|
||||
@@ -5,14 +5,25 @@ export async function waitForElm(
|
||||
selector: string,
|
||||
usePolling: boolean = false,
|
||||
interval: number = 100,
|
||||
maxIterations?: number
|
||||
): Promise<Element> {
|
||||
if (usePolling) {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let iterations = 0;
|
||||
if (maxIterations) {
|
||||
iterations = 0;
|
||||
}
|
||||
const checkForElement = () => {
|
||||
const element = document.querySelector(selector)
|
||||
if (element) {
|
||||
resolve(element)
|
||||
} else {
|
||||
if (maxIterations) {
|
||||
iterations++;
|
||||
if (iterations >= maxIterations) {
|
||||
reject(new Error("Element not found"));
|
||||
}
|
||||
}
|
||||
setTimeout(checkForElement, interval)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user