···11+# .env file is optional to automatically open an authenticated schoolbox session during development
22+33+# base URL, excluding trailing slashes
44+WXT_SCHOOLBOX_URL="https://help.schoolbox.com.au"
55+66+# JSON web token, found at `base_url/user/token`
77+WXT_SCHOOLBOX_JWT="eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InNjaG9vbGJveCJ9..."
···11+import { Snippet } from "@/utils/snippet";
22+import styles from "./styles.css?inline";
33+44+export default new Snippet(
55+ {
66+ id: "censor",
77+ name: "Censor",
88+ description: "Censors all text and images. This is intended for development purposes.",
99+ },
1010+ false,
1111+ styles,
1212+);
···11+import { Snippet } from "@/utils/snippet";
22+import styles from "./styles.css?inline";
33+44+export default new Snippet(
55+ {
66+ id: "hidePfp",
77+ name: "Hide Profile Picture",
88+ description: "Hide your profile picture across Schoolbox.",
99+ },
1010+ true,
1111+ styles,
1212+);
+12
src/entrypoints/snippets/hidePwaPrompt/index.ts
···11+import { Snippet } from "@/utils/snippet";
22+import styles from "./styles.css?inline";
33+44+export default new Snippet(
55+ {
66+ id: "hidePwaPrompt",
77+ name: "Hide PWA Prompt",
88+ description: "Hide the prompt in the notifications menu to install Schoolbox as a PWA and enable notifications.",
99+ },
1010+ true,
1111+ styles,
1212+);
···11// these utility functions are intended to be used on the dashboard, as that is where the timetable is displayed
2233-import { logger } from "./logger";
44-53interface PeriodHeader {
64 name: string;
75 time: {
···138136 return date;
139137 });
140138 return { start, end };
141141- } catch (error) {
142142- logger.error("Error extracting times:", error);
143143- throw new Error("Failed to extract times");
139139+ } catch (e) {
140140+ throw new Error("Failed to extract times", e as Error);
144141 }
145142}
+100-60
src/utils/plugin.ts
···11+import { storage } from "#imports";
22+import { hasChanged, onSchoolboxPage } from ".";
13import { logger } from "./logger";
22-import type { PluginId, PluginSetting, Slider } from "./storage";
33-import { globalSettings, plugins, schoolboxUrls } from "./storage";
44-55-export async function definePlugin(
66- pluginId: PluginId,
77- callback: (settings?: { toggle: Record<string, boolean>; slider: Record<string, Slider> }) => Promise<void> | void,
88- elementsToWaitFor: string[] = [],
99-) {
1010- const plugin = await plugins[pluginId].toggle.storage.getValue();
1111-1212- logger.info(`${plugins[pluginId].name}: ${plugin.toggle ? "enabled" : "disabled"}`);
44+import type { Toggle } from "./storage";
55+import { globalSettings } from "./storage";
66+import { StorageState } from "./storage/state.svelte";
1371414- const settings = await globalSettings.storage.getValue();
1515- const urls = (await schoolboxUrls.storage.getValue()).urls;
88+export class Plugin<T extends Record<string, unknown> | undefined = undefined> {
99+ private injected = false;
1010+ public toggle: StorageState<Toggle>;
1111+ public settings!: T;
16121717- if (plugin && typeof window !== "undefined" && urls.includes(window.location.origin)) {
1818- if (settings.global && settings.plugins && plugin.toggle) {
1919- const injectPlugin = () => {
2020- callback(getSettingsValues(plugins[pluginId]?.settings));
2121- };
1313+ constructor(
1414+ public meta: {
1515+ id: string;
1616+ name: string;
1717+ description: string;
1818+ },
1919+ defaultToggle: boolean,
2020+ settings: Record<string, object> | null,
2121+ private injectCallback: (settings: T) => Promise<void> | void,
2222+ private uninjectCallback: (settings: T) => Promise<void> | void,
2323+ private elementsToWaitFor: string[] = [],
2424+ ) {
2525+ // init plugin storage
2626+ this.toggle = new StorageState(
2727+ storage.defineItem(`local:plugin-${meta.id}`, {
2828+ fallback: { toggle: defaultToggle },
2929+ }),
3030+ );
3131+ if (settings) {
3232+ this.settings = Object.fromEntries(
3333+ Object.entries(settings).map(([key, value]) => [
3434+ key,
3535+ new StorageState(
3636+ storage.defineItem(`local:plugin-${meta.id}-${key}`, {
3737+ fallback: value,
3838+ }),
3939+ ),
4040+ ]),
4141+ ) as T;
4242+ }
4343+ }
22442323- const loadPlugin = () => {
2424- // wait for elements to be loaded
2525- if (elementsToWaitFor.length > 0) {
2626- const observer = new MutationObserver((_mutations, observer) => {
2727- const allElementsPresent = elementsToWaitFor.every((selector) => document.querySelector(selector) !== null);
2828- if (allElementsPresent) {
2929- observer.disconnect();
3030- logger.info(`all elements present, injecting plugin: ${plugins[pluginId].name}`);
3131- injectPlugin();
3232- }
3333- });
4545+ async init() {
4646+ // if not on Schoolbox page
4747+ if (!(await onSchoolboxPage())) return;
34483535- observer.observe(document.body, { childList: true, subtree: true });
4949+ logger.info(`init plugin: ${this.meta.name}`);
36503737- // check if elements are already present
3838- const allElementsPresent = elementsToWaitFor.every((selector) => document.querySelector(selector) !== null);
3939- if (allElementsPresent) {
5151+ if (await this.isEnabled()) {
5252+ // wait for elements to be loaded
5353+ if (this.elementsToWaitFor.length > 0) {
5454+ // create an observer to wait for all elements to be loaded
5555+ const observer = new MutationObserver((_mutations, observer) => {
5656+ if (this.allElementsPresent()) {
4057 observer.disconnect();
4141- logger.info(`all elements already present, injecting plugin: ${plugins[pluginId].name}`);
4242- injectPlugin();
5858+ this.inject();
4359 }
4444- } else {
4545- // no elements to wait for
4646- logger.info(`injecting plugin: ${plugins[pluginId].name}`);
4747- injectPlugin();
4848- }
4949- };
6060+ });
6161+ observer.observe(document.body, { childList: true, subtree: true });
50625151- if (document.body) {
5252- loadPlugin();
6363+ // check if elements are already present
6464+ if (this.allElementsPresent()) {
6565+ observer.disconnect();
6666+ this.inject();
6767+ }
5368 } else {
5454- document.addEventListener("DOMContentLoaded", loadPlugin);
6969+ // no elements to wait for
7070+ this.inject();
7171+ }
7272+ }
7373+7474+ // init watchers
7575+ globalSettings.watch((newValue, oldValue) => {
7676+ if (hasChanged(newValue, oldValue, ["global", "plugins"])) this.reload();
7777+ });
7878+ this.toggle.watch(this.reload.bind(this));
7979+ if (this.settings) {
8080+ for (const setting of Object.values(this.settings)) {
8181+ if (!(setting instanceof StorageState)) continue;
8282+ setting.watch(this.reload.bind(this));
5583 }
5684 }
5785 }
5858-}
59866060-function getSettingsValues(settings?: Record<string, PluginSetting>) {
6161- if (!settings) return undefined;
8787+ private inject() {
8888+ if (this.injected) return;
8989+ if (!this.allElementsPresent()) return;
9090+ logger.info(`injecting plugin: ${this.meta.name}`);
9191+ this.injectCallback(this.settings);
9292+ this.injected = true;
9393+ }
62946363- const result: {
6464- toggle: Record<string, boolean>;
6565- slider: Record<string, Slider>;
6666- } = { toggle: {}, slider: {} };
6767- for (const [key, setting] of Object.entries(settings)) {
6868- if (setting.type === "toggle") {
6969- const value = setting.state.get();
7070- result.toggle[key] = value.toggle;
7171- } else if (setting.type === "slider") {
7272- const value = setting.state.get();
7373- result.slider[key] = value;
7474- }
9595+ private uninject() {
9696+ if (!this.injected) return;
9797+ logger.info(`uninjecting plugin: ${this.meta.name}`);
9898+ this.uninjectCallback(this.settings);
9999+ this.injected = false;
75100 }
7676- return result;
101101+102102+ private async reload() {
103103+ if (this.injected) this.uninject();
104104+ if (await this.isEnabled()) this.inject();
105105+ }
106106+107107+ private async isEnabled(): Promise<boolean> {
108108+ const settings = await globalSettings.get();
109109+ const toggle = await this.toggle.get();
110110+111111+ return settings.global && settings.plugins && toggle.toggle;
112112+ }
113113+114114+ private allElementsPresent() {
115115+ return this.elementsToWaitFor.every((selector) => document.querySelector(selector) !== null);
116116+ }
77117}