schoolbox web extension :)
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(plugins): `Plugin` class (#318)

* refactor(plugins): use class instead of helper

* close #314

* feat: plugin injection

* fix: plugin injection and popup

* fix(plugins): only inject if elements present

* refactor(plugins): manage config in plugin modules

* close #315

* fix: lint errors

authored by

willow and committed by
GitHub
bedf7243 e248a2a4

+589 -600
+9 -7
src/entrypoints/plugins.content.ts
··· 8 8 import subheader from "./plugins/subheader"; 9 9 import tabTitle from "./plugins/tabTitle"; 10 10 11 + export const plugins = [subheader, scrollSegments, scrollPeriod, progressBar, modernIcons, tabTitle, homepageSwitcher]; 12 + 13 + export type PluginInstance = (typeof plugins)[number]; 14 + 11 15 export default defineContentScript({ 12 16 matches: ["<all_urls>"], 13 17 runAt: "document_start", 14 18 excludeMatches: EXCLUDE_MATCHES, 15 19 async main() { 16 - subheader(); 17 - scrollSegments(); 18 - scrollPeriod(); 19 - progressBar(); 20 - modernIcons(); 21 - tabTitle(); 22 - homepageSwitcher(); 20 + document.addEventListener("DOMContentLoaded", () => { 21 + for (const plugin of plugins) { 22 + plugin.init(); 23 + } 24 + }); 23 25 }, 24 26 });
-50
src/entrypoints/plugins/homepageSwitcher.ts
··· 1 - import { browser } from "#imports"; 2 - import { definePlugin } from "@/utils/plugin"; 3 - 4 - let logos: HTMLAnchorElement[] | null = null; 5 - let controller: AbortController | null = null; 6 - 7 - export default function init() { 8 - definePlugin( 9 - "homepageSwitcher", 10 - (settings) => { 11 - if (logos !== null) return; 12 - 13 - logos = Array.from(document.getElementsByClassName("logo")) as HTMLAnchorElement[]; 14 - 15 - // add event listeners 16 - const closeCurrentTab = settings?.toggle.closeCurrentTab === true; 17 - controller = new AbortController(); 18 - 19 - for (const logo of logos) { 20 - logo.addEventListener( 21 - "click", 22 - (e) => { 23 - if (window.location.pathname === "/") return; 24 - 25 - e.preventDefault(); 26 - 27 - if (logos) { 28 - const tab = logos[0].href; 29 - if (closeCurrentTab) window.close(); // TODO: Scripts may only close windows that were opened by a script. 30 - browser.runtime.sendMessage({ toTab: tab }); 31 - } 32 - }, 33 - { 34 - signal: controller.signal, 35 - }, 36 - ); 37 - } 38 - }, 39 - () => { 40 - // remove event listeners 41 - if (controller) { 42 - controller.abort(); 43 - controller = null; 44 - } 45 - 46 - logos = null; 47 - }, 48 - [".logo"], 49 - ); 50 - }
+16
src/entrypoints/plugins/homepageSwitcher/Menu.svelte
··· 1 + <script lang="ts"> 2 + import Toggle from "@/entrypoints/popup/components/inputs/Toggle.svelte"; 3 + import type { Settings } from "."; 4 + 5 + let { settings }: { settings: Settings } = $props(); 6 + </script> 7 + 8 + <Toggle 9 + text="Close current tab" 10 + description="When switching to another tab, close the current one." 11 + size="small" 12 + id="closeCurrentTab" 13 + checked={settings.closeCurrentTab.state.toggle} 14 + update={async (toggle) => { 15 + settings.closeCurrentTab.set({ toggle }); 16 + }} />
+68
src/entrypoints/plugins/homepageSwitcher/index.ts
··· 1 + import { browser } from "#imports"; 2 + import { Plugin } from "@/utils/plugin"; 3 + import type { Toggle } from "@/utils/storage"; 4 + import type { StorageState } from "@/utils/storage/state.svelte"; 5 + import menu from "./Menu.svelte?url"; 6 + 7 + let logos: HTMLAnchorElement[] | null = null; 8 + let controller: AbortController | null = null; 9 + 10 + export type Settings = { 11 + closeCurrentTab: StorageState<Toggle>; 12 + }; 13 + 14 + export default new Plugin<Settings>( 15 + { 16 + id: "homepageSwitcher", 17 + name: "Homepage Switcher", 18 + description: "The logo will switch to existing Schoolbox homepage when available.", 19 + }, 20 + false, 21 + { 22 + config: { 23 + closeCurrentTab: { 24 + toggle: false, 25 + }, 26 + }, 27 + menu, 28 + }, 29 + async (settings) => { 30 + if (logos !== null) return; 31 + 32 + logos = Array.from(document.getElementsByClassName("logo")) as HTMLAnchorElement[]; 33 + 34 + // add event listeners 35 + const closeCurrentTab = await settings.closeCurrentTab.get(); 36 + controller = new AbortController(); 37 + 38 + for (const logo of logos) { 39 + logo.addEventListener( 40 + "click", 41 + (e) => { 42 + if (window.location.pathname === "/") return; 43 + 44 + e.preventDefault(); 45 + 46 + if (logos) { 47 + const tab = logos[0].href; 48 + if (closeCurrentTab.toggle) window.close(); // TODO: Scripts may only close windows that were opened by a script. 49 + browser.runtime.sendMessage({ toTab: tab }); 50 + } 51 + }, 52 + { 53 + signal: controller.signal, 54 + }, 55 + ); 56 + } 57 + }, 58 + () => { 59 + // remove event listeners 60 + if (controller) { 61 + controller.abort(); 62 + controller = null; 63 + } 64 + 65 + logos = null; 66 + }, 67 + [".logo"], 68 + );
+16
src/entrypoints/plugins/modernIcons/Menu.svelte
··· 1 + <script lang="ts"> 2 + import Toggle from "@/entrypoints/popup/components/inputs/Toggle.svelte"; 3 + import type { Settings } from "."; 4 + 5 + let { settings }: { settings: Settings } = $props(); 6 + </script> 7 + 8 + <Toggle 9 + text="Filled icons" 10 + description="Whether the icons should be filled or outlined." 11 + size="small" 12 + id="filled" 13 + checked={settings.filled.state.toggle} 14 + update={async (toggle) => { 15 + settings.filled.set({ toggle }); 16 + }} />
+40 -23
src/entrypoints/plugins/modernIcons/index.ts
··· 6 6 uninjectInlineStyles, 7 7 uninjectStylesheet, 8 8 } from "@/utils"; 9 - import { definePlugin } from "@/utils/plugin"; 9 + import { Plugin } from "@/utils/plugin"; 10 10 import styleText from "./styles.css?inline"; 11 + import type { Toggle } from "@/utils/storage"; 12 + import type { StorageState } from "@/utils/storage/state.svelte"; 13 + import menu from "./Menu.svelte?url"; 11 14 12 15 const ID = "modernIcons"; 13 16 const PLUGIN_ID = `plugin-${ID}`; 14 17 15 - export default function init() { 16 - definePlugin( 17 - ID, 18 - async (settings) => { 19 - const iconNames = [...new Set(Object.values(icons))].sort(); 20 - const fontUrl = `https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:FILL@0..1&icon_names=${iconNames.join(",")}`; 18 + export type Settings = { 19 + filled: StorageState<Toggle>; 20 + }; 21 + 22 + export default new Plugin<Settings>( 23 + { 24 + id: ID, 25 + name: "Modern Icons", 26 + description: "Modernise the icons across Schoolbox.", 27 + }, 28 + true, 29 + { 30 + config: { 31 + filled: { toggle: true }, 32 + }, 33 + menu, 34 + }, 35 + 36 + async (settings) => { 37 + const iconNames = [...new Set(Object.values(icons))].sort(); 38 + const fontUrl = `https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:FILL@0..1&icon_names=${iconNames.join(",")}`; 21 39 22 - // inject font face 23 - injectStylesheet(fontUrl, PLUGIN_ID); 40 + // inject font face 41 + injectStylesheet(fontUrl, PLUGIN_ID); 24 42 25 - // inject icon styling 26 - injectInlineStyles(styleText, PLUGIN_ID); 43 + // inject icon styling 44 + injectInlineStyles(styleText, PLUGIN_ID); 27 45 28 - // inject icons 29 - const filled = settings?.toggle.filled ?? false; 30 - injectIcons(icons, filled); 31 - }, 32 - () => { 33 - uninjectStylesheet(PLUGIN_ID); 34 - uninjectInlineStyles(PLUGIN_ID); 35 - uninjectIcons(); 36 - }, 37 - ["nav.tab-bar .top-menu", "#overflow-nav"], 38 - ); 39 - } 46 + // inject icons 47 + const filled = await settings.filled.get(); 48 + injectIcons(icons, filled.toggle); 49 + }, 50 + () => { 51 + uninjectStylesheet(PLUGIN_ID); 52 + uninjectInlineStyles(PLUGIN_ID); 53 + uninjectIcons(); 54 + }, 55 + ["nav.tab-bar .top-menu", "#overflow-nav"], 56 + ); 40 57 41 58 // [className, iconName] (material icons) 42 59 const icons = {
+26 -22
src/entrypoints/plugins/progressBar/index.ts
··· 1 1 import { dataAttr, injectInlineStyles, setDataAttr, uninjectInlineStyles } from "@/utils"; 2 2 import type { Period } from "@/utils/periodUtils"; 3 3 import { getListOfPeriods } from "@/utils/periodUtils"; 4 - import { definePlugin } from "@/utils/plugin"; 4 + import { Plugin } from "@/utils/plugin"; 5 5 import styleText from "./styles.css?inline"; 6 6 7 7 const ID = "progressBar"; 8 8 const PLUGIN_ID = `plugin-${ID}`; 9 9 10 - export default function init() { 11 - definePlugin( 12 - ID, 13 - () => { 14 - if (window.location.pathname === "/" && document.querySelector(".timetable")) { 15 - const periodList = getListOfPeriods(); 10 + export default new Plugin( 11 + { 12 + id: ID, 13 + name: "Progress Bar", 14 + description: "Displays a progress bar below the timetable to show the time of the day.", 15 + }, 16 + true, 17 + null, 18 + () => { 19 + if (window.location.pathname === "/" && document.querySelector(".timetable")) { 20 + const periodList = getListOfPeriods(); 16 21 17 - const progressRow = document.createElement("tr"); 18 - progressRow.classList.add("progress-container"); 19 - document.querySelector(".timetable > thead")?.insertAdjacentElement("beforeend", progressRow); 22 + const progressRow = document.createElement("tr"); 23 + progressRow.classList.add("progress-container"); 24 + document.querySelector(".timetable > thead")?.insertAdjacentElement("beforeend", progressRow); 20 25 21 - injectInlineStyles(styleText, PLUGIN_ID); 22 - injectProgressBars(periodList, progressRow); 26 + injectInlineStyles(styleText, PLUGIN_ID); 27 + injectProgressBars(periodList, progressRow); 23 28 24 - setDataAttr(progressRow, `${PLUGIN_ID}-row`); 25 - } 26 - }, 27 - () => { 28 - uninjectInlineStyles(PLUGIN_ID); 29 - uninjectProgressBars(); 30 - }, 31 - [".timetable"], 32 - ); 33 - } 29 + setDataAttr(progressRow, `${PLUGIN_ID}-row`); 30 + } 31 + }, 32 + () => { 33 + uninjectInlineStyles(PLUGIN_ID); 34 + uninjectProgressBars(); 35 + }, 36 + [".timetable"], 37 + ); 34 38 35 39 function injectProgressBars(periodList: Period[], container: HTMLElement) { 36 40 if (document.querySelector(dataAttr(`${PLUGIN_ID}-row`))) return;
-65
src/entrypoints/plugins/scrollPeriod.ts
··· 1 - import { getCurrentPeriod } from "@/utils/periodUtils"; 2 - import { definePlugin } from "@/utils/plugin"; 3 - 4 - let interval: NodeJS.Timeout | null = null; 5 - let controller: AbortController | null = null; 6 - 7 - export default function init() { 8 - definePlugin( 9 - "scrollPeriod", 10 - async (settings) => { 11 - const timetable = document.querySelector("[data-timetable-container] div.scrollable"); 12 - 13 - if (window.location.pathname === "/" && timetable) { 14 - updateScrollbar(timetable); 15 - 16 - const cooldownDuration = settings?.slider.cooldownDuration; 17 - const resetCooldownOnMouseMove = settings?.toggle.resetCooldownOnMouseMove; 18 - 19 - const setUpdateInterval = () => { 20 - interval = setInterval(() => updateScrollbar(timetable), (cooldownDuration?.value || 10) * 1000); 21 - }; 22 - 23 - setUpdateInterval(); 24 - 25 - if (resetCooldownOnMouseMove === true) { 26 - controller = new AbortController(); 27 - document.addEventListener( 28 - "mousemove", 29 - () => { 30 - if (interval) { 31 - clearInterval(interval); 32 - setUpdateInterval(); 33 - } 34 - }, 35 - { signal: controller.signal }, 36 - ); 37 - } 38 - } 39 - }, 40 - () => { 41 - if (controller) { 42 - controller.abort(); 43 - controller = null; 44 - } 45 - if (interval) { 46 - clearInterval(interval); 47 - interval = null; 48 - } 49 - }, 50 - [".timetable"], 51 - ); 52 - } 53 - 54 - function updateScrollbar(timetable: Element) { 55 - const currentPeriod = getCurrentPeriod(); 56 - if (currentPeriod && currentPeriod.index && timetable) { 57 - const period = document.querySelector(`.timetable thead tr th:nth-child(${currentPeriod.index})`) as HTMLElement; 58 - if (period) { 59 - timetable.scroll({ 60 - left: period.offsetLeft - 55, // adjusted for alignment 61 - behavior: "smooth", // or 'auto' for instant scroll 62 - }); 63 - } 64 - } 65 - }
+23
src/entrypoints/plugins/scrollPeriod/Menu.svelte
··· 1 + <script lang="ts"> 2 + import Toggle from "@/entrypoints/popup/components/inputs/Toggle.svelte"; 3 + import Slider from "@/entrypoints/popup/components/inputs/Slider.svelte"; 4 + import type { Settings } from "."; 5 + 6 + let { settings }: { settings: Settings } = $props(); 7 + </script> 8 + 9 + <Toggle 10 + text="Reset on mouse move" 11 + description="Whether to reset the scrolling cooldown when you move your mouse." 12 + size="small" 13 + id="resetCooldownOnMouseMove" 14 + checked={settings.resetCooldownOnMouseMove.state.toggle} 15 + update={async (toggle) => { 16 + settings.resetCooldownOnMouseMove.set({ toggle }); 17 + }} /> 18 + 19 + <Slider 20 + name="Cooldown duration" 21 + id="cooldownDuration" 22 + update={(value) => settings.cooldownDuration.update({ value })} 23 + {...settings.cooldownDuration.state} />
+83
src/entrypoints/plugins/scrollPeriod/index.ts
··· 1 + import { getCurrentPeriod } from "@/utils/periodUtils"; 2 + import { Plugin } from "@/utils/plugin"; 3 + import type { Slider, Toggle } from "@/utils/storage"; 4 + import type { StorageState } from "@/utils/storage/state.svelte"; 5 + import menu from "./Menu.svelte?url"; 6 + 7 + let interval: NodeJS.Timeout | null = null; 8 + let controller: AbortController | null = null; 9 + 10 + export type Settings = { 11 + resetCooldownOnMouseMove: StorageState<Toggle>; 12 + cooldownDuration: StorageState<Slider>; 13 + }; 14 + 15 + export default new Plugin<Settings>( 16 + { 17 + id: "scrollPeriod", 18 + name: "Scroll Period", 19 + description: "Scrolls to the current period on the timetable.", 20 + }, 21 + true, 22 + { 23 + config: { 24 + resetCooldownOnMouseMove: { toggle: true }, 25 + cooldownDuration: { min: 1, max: 60, value: 10 }, 26 + }, 27 + menu, 28 + }, 29 + async (settings) => { 30 + const timetable = document.querySelector("[data-timetable-container] div.scrollable"); 31 + 32 + if (window.location.pathname === "/" && timetable) { 33 + updateScrollbar(timetable); 34 + 35 + const cooldownDuration = await settings.cooldownDuration.get(); 36 + const resetCooldownOnMouseMove = await settings.resetCooldownOnMouseMove.get(); 37 + 38 + const setUpdateInterval = () => { 39 + interval = setInterval(() => updateScrollbar(timetable), (cooldownDuration?.value || 10) * 1000); 40 + }; 41 + 42 + setUpdateInterval(); 43 + 44 + if (resetCooldownOnMouseMove.toggle === true) { 45 + controller = new AbortController(); 46 + document.addEventListener( 47 + "mousemove", 48 + () => { 49 + if (interval) { 50 + clearInterval(interval); 51 + setUpdateInterval(); 52 + } 53 + }, 54 + { signal: controller.signal }, 55 + ); 56 + } 57 + } 58 + }, 59 + () => { 60 + if (controller) { 61 + controller.abort(); 62 + controller = null; 63 + } 64 + if (interval) { 65 + clearInterval(interval); 66 + interval = null; 67 + } 68 + }, 69 + [".timetable"], 70 + ); 71 + 72 + function updateScrollbar(timetable: Element) { 73 + const currentPeriod = getCurrentPeriod(); 74 + if (currentPeriod && currentPeriod.index && timetable) { 75 + const period = document.querySelector(`.timetable thead tr th:nth-child(${currentPeriod.index})`) as HTMLElement; 76 + if (period) { 77 + timetable.scroll({ 78 + left: period.offsetLeft - 55, // adjusted for alignment 79 + behavior: "smooth", // or 'auto' for instant scroll 80 + }); 81 + } 82 + } 83 + }
+37 -33
src/entrypoints/plugins/scrollSegments/index.ts
··· 1 1 import { dataAttr, injectInlineStyles, setDataAttr, uninjectInlineStyles } from "@/utils"; 2 - import { definePlugin } from "@/utils/plugin"; 2 + import { Plugin } from "@/utils/plugin"; 3 3 import styleText from "./styles.css?inline"; 4 4 5 5 const ID = "scrollSegments"; 6 6 const PLUGIN_ID = `plugin-${ID}`; 7 7 8 - export default function init() { 9 - definePlugin( 10 - ID, 11 - () => { 12 - const footerCopy = document.querySelector(dataAttr(PLUGIN_ID)); 13 - if (footerCopy) return; 8 + export default new Plugin( 9 + { 10 + id: ID, 11 + name: "Scroll Segments", 12 + description: "Segments the Schoolbox page into scrollable sections.", 13 + }, 14 + true, 15 + null, 16 + () => { 17 + const footerCopy = document.querySelector(dataAttr(PLUGIN_ID)); 18 + if (footerCopy) return; 14 19 15 - // scroll to top to avoid hot reload bug 16 - window.scrollTo({ 17 - top: 0, 18 - behavior: "instant", 19 - }); 20 + // scroll to top to avoid hot reload bug 21 + window.scrollTo({ 22 + top: 0, 23 + behavior: "instant", 24 + }); 20 25 21 - const content = document.querySelector("#content"); 22 - const footer = document.querySelector<HTMLDivElement>("#footer"); 26 + const content = document.querySelector("#content"); 27 + const footer = document.querySelector<HTMLDivElement>("#footer"); 23 28 24 - // add copy of footer to content 25 - if (content && footer) { 26 - const clone = footer.cloneNode(true) as HTMLDivElement; 27 - setDataAttr(clone, PLUGIN_ID); 28 - content.appendChild(clone); 29 - } 29 + // add copy of footer to content 30 + if (content && footer) { 31 + const clone = footer.cloneNode(true) as HTMLDivElement; 32 + setDataAttr(clone, PLUGIN_ID); 33 + content.appendChild(clone); 34 + } 30 35 31 - injectInlineStyles(styleText, PLUGIN_ID); 32 - }, 33 - () => { 34 - const footerCopy = document.querySelector(dataAttr(PLUGIN_ID)); 35 - if (!footerCopy) return; 36 + injectInlineStyles(styleText, PLUGIN_ID); 37 + }, 38 + () => { 39 + const footerCopy = document.querySelector(dataAttr(PLUGIN_ID)); 40 + if (!footerCopy) return; 36 41 37 - // remove copy of footer from content 38 - const content = document.querySelector("#content"); 39 - content?.removeChild(footerCopy); 42 + // remove copy of footer from content 43 + const content = document.querySelector("#content"); 44 + content?.removeChild(footerCopy); 40 45 41 - uninjectInlineStyles(PLUGIN_ID); 42 - }, 43 - ["#content", "#footer"], 44 - ); 45 - } 46 + uninjectInlineStyles(PLUGIN_ID); 47 + }, 48 + ["#content", "#footer"], 49 + );
+15
src/entrypoints/plugins/subheader/Menu.svelte
··· 1 + <script lang="ts"> 2 + import Toggle from "@/entrypoints/popup/components/inputs/Toggle.svelte"; 3 + import type { Settings } from "."; 4 + 5 + let { settings }: { settings: Settings } = $props(); 6 + </script> 7 + 8 + <Toggle 9 + text="Open links in new tab" 10 + size="small" 11 + id="openInNewTab" 12 + checked={settings.openInNewTab.state.toggle} 13 + update={async (toggle) => { 14 + settings.openInNewTab.set({ toggle }); 15 + }} />
+27 -11
src/entrypoints/plugins/subheader/index.ts
··· 1 1 import { getCurrentPeriod } from "@/utils/periodUtils"; 2 - import { definePlugin } from "@/utils/plugin"; 2 + import { Plugin } from "@/utils/plugin"; 3 3 import styleText from "./styles.css?inline"; 4 4 import { dataAttr, injectInlineStyles, setDataAttr, uninjectInlineStyles } from "@/utils"; 5 + import type { StorageState } from "@/utils/storage/state.svelte"; 6 + import type { Toggle } from "@/utils/storage"; 7 + import menu from "./Menu.svelte?url"; 5 8 6 9 const ID = "subheader"; 7 10 const PLUGIN_ID = `plugin-${ID}`; ··· 10 13 let oldChildren: ChildNode[] = []; 11 14 let subheader: HTMLHeadingElement | null = null; 12 15 13 - export default function init() { 14 - definePlugin( 15 - "subheader", 16 - (settings) => { 17 - const openInNewTab = settings?.toggle.openInNewTab ?? false; 18 - injectSubheader(openInNewTab); 16 + export type Settings = { 17 + openInNewTab: StorageState<Toggle>; 18 + }; 19 + 20 + export default new Plugin<Settings>( 21 + { 22 + id: ID, 23 + name: "Subheader Revamp", 24 + description: "Adds a clock and current period info to the subheader.", 25 + }, 26 + true, 27 + { 28 + config: { 29 + openInNewTab: { toggle: true }, 19 30 }, 20 - uninjectSubheader, 21 - [".subheader", ".timetable"], 22 - ); 23 - } 31 + menu, 32 + }, 33 + async (settings) => { 34 + const openInNewTab = await settings.openInNewTab.get(); 35 + injectSubheader(openInNewTab.toggle); 36 + }, 37 + uninjectSubheader, 38 + [".subheader", ".timetable"], 39 + ); 24 40 25 41 function injectSubheader(openInNewTab: boolean) { 26 42 // abort if plugin is injected
-65
src/entrypoints/plugins/tabTitle.ts
··· 1 - import { definePlugin } from "@/utils/plugin"; 2 - 3 - const ID = "tabTitle"; 4 - let originalTitle: string | null = null; 5 - 6 - export default function init() { 7 - definePlugin( 8 - ID, 9 - async (settings) => { 10 - // if already injected, abort 11 - if (originalTitle) return; 12 - 13 - // backup original title (used for uninjection) 14 - originalTitle = document.title; 15 - 16 - const path = window.location.pathname; 17 - const titleMap: { [key: string]: string } = { 18 - "/": "Homepage", 19 - "/calendar": "Calendar", 20 - "/news": "News", 21 - "/learning/classes": "Classes", 22 - "/resources": "Resources", 23 - "/groups": "Groups", 24 - "/settings/notifications": "Notifications Settings", 25 - "/mail/create": "Compose Email", 26 - "/feedback": "Support and Feedback", 27 - "/policy": "Guidelines of Use and Privacy Policy", 28 - }; 29 - 30 - if (titleMap[path]) { 31 - document.title = titleMap[path]; 32 - } else if (path.includes("/timetable")) { 33 - document.title = "Timetable"; 34 - } else if (path.includes("/calendar")) { 35 - document.title = "Calendar"; 36 - } else if (path.includes("/grades/")) { 37 - document.title = "Grades"; 38 - } else if (path.includes("/news/")) { 39 - document.title = `News (${document.getElementsByTagName("h1")[0].innerText})`; 40 - } else if (path.includes("/assessments/")) { 41 - document.title = `Assessments - ${document.getElementsByTagName("h1")[0].innerText})`; 42 - } else if (path.includes("/mail/create")) { 43 - document.title = "Compose Email"; 44 - } else if (path.includes("/search/user")) { 45 - document.title = `Profile - ${document.getElementsByTagName("h1")[0].innerText}`; 46 - } else if (path.includes("/learning/due/")) { 47 - document.title = "Due Work"; 48 - } else if (path.includes("/homepage/")) { 49 - if (settings?.toggle.showSubjectPrefix === false) { 50 - document.title = document.getElementsByTagName("h1")[0].innerText.replace(/^.*- /, ""); 51 - } else { 52 - document.title = document.getElementsByTagName("h1")[0].innerText; 53 - } 54 - } 55 - }, 56 - () => { 57 - // if not injected, abort 58 - if (!originalTitle) return; 59 - 60 - document.title = originalTitle; 61 - originalTitle = null; 62 - }, 63 - ["h1"], 64 - ); 65 - }
+16
src/entrypoints/plugins/tabTitle/Menu.svelte
··· 1 + <script lang="ts"> 2 + import Toggle from "@/entrypoints/popup/components/inputs/Toggle.svelte"; 3 + import type { Settings } from "."; 4 + 5 + let { settings }: { settings: Settings } = $props(); 6 + </script> 7 + 8 + <Toggle 9 + text="Show subject prefix" 10 + description="e.g. 'ENG - VCE English 1 & 2' becomes 'VCE English 1 & 2'" 11 + size="small" 12 + id="showSubjectPrefix" 13 + checked={settings.showSubjectPrefix.state.toggle} 14 + update={async (toggle) => { 15 + settings.showSubjectPrefix.set({ toggle }); 16 + }} />
+81
src/entrypoints/plugins/tabTitle/index.ts
··· 1 + import { Plugin } from "@/utils/plugin"; 2 + import type { Toggle } from "@/utils/storage"; 3 + import type { StorageState } from "@/utils/storage/state.svelte"; 4 + import menu from "./Menu.svelte?url"; 5 + 6 + const ID = "tabTitle"; 7 + let originalTitle: string | null = null; 8 + 9 + export type Settings = { 10 + showSubjectPrefix: StorageState<Toggle>; 11 + }; 12 + 13 + export default new Plugin<Settings>( 14 + { 15 + id: ID, 16 + name: "Better Tab Titles", 17 + description: "Improves the tab titles for easier navigation.", 18 + }, 19 + true, 20 + { 21 + config: { 22 + showSubjectPrefix: { toggle: true }, 23 + }, 24 + menu, 25 + }, 26 + async (settings) => { 27 + // if already injected, abort 28 + if (originalTitle) return; 29 + 30 + // backup original title (used for uninjection) 31 + originalTitle = document.title; 32 + 33 + const path = window.location.pathname; 34 + const titleMap: { [key: string]: string } = { 35 + "/": "Homepage", 36 + "/calendar": "Calendar", 37 + "/news": "News", 38 + "/learning/classes": "Classes", 39 + "/resources": "Resources", 40 + "/groups": "Groups", 41 + "/settings/notifications": "Notifications Settings", 42 + "/mail/create": "Compose Email", 43 + "/feedback": "Support and Feedback", 44 + "/policy": "Guidelines of Use and Privacy Policy", 45 + }; 46 + 47 + if (titleMap[path]) { 48 + document.title = titleMap[path]; 49 + } else if (path.includes("/timetable")) { 50 + document.title = "Timetable"; 51 + } else if (path.includes("/calendar")) { 52 + document.title = "Calendar"; 53 + } else if (path.includes("/grades/")) { 54 + document.title = "Grades"; 55 + } else if (path.includes("/news/")) { 56 + document.title = `News (${document.getElementsByTagName("h1")[0].innerText})`; 57 + } else if (path.includes("/assessments/")) { 58 + document.title = `Assessments - ${document.getElementsByTagName("h1")[0].innerText})`; 59 + } else if (path.includes("/mail/create")) { 60 + document.title = "Compose Email"; 61 + } else if (path.includes("/search/user")) { 62 + document.title = `Profile - ${document.getElementsByTagName("h1")[0].innerText}`; 63 + } else if (path.includes("/learning/due/")) { 64 + document.title = "Due Work"; 65 + } else if (path.includes("/homepage/")) { 66 + if (!(await settings.showSubjectPrefix.get()).toggle) { 67 + document.title = document.getElementsByTagName("h1")[0].innerText.replace(/^.*- /, ""); 68 + } else { 69 + document.title = document.getElementsByTagName("h1")[0].innerText; 70 + } 71 + } 72 + }, 73 + () => { 74 + // if not injected, abort 75 + if (!originalTitle) return; 76 + 77 + document.title = originalTitle; 78 + originalTitle = null; 79 + }, 80 + ["h1"], 81 + );
+25 -38
src/entrypoints/popup/routes/Plugins.svelte
··· 1 1 <script lang="ts"> 2 - import type { PluginId } from "@/utils/storage"; 3 - import { globalSettings, plugins } from "@/utils/storage"; 2 + import { globalSettings } from "@/utils/storage"; 4 3 import { Settings } from "@lucide/svelte"; 5 4 import Title from "../components/Title.svelte"; 6 5 import Button from "../components/inputs/Button.svelte"; 7 6 import Modal from "../components/Modal.svelte"; 8 7 import Toggle from "../components/inputs/Toggle.svelte"; 9 - import Slider from "../components/inputs/Slider.svelte"; 8 + import { plugins } from "@/entrypoints/plugins.content"; 9 + import type { PluginInstance } from "@/entrypoints/plugins.content"; 10 + import { onMount } from "svelte"; 11 + import type { Component } from "svelte"; 10 12 11 13 let showModal = $state(false); 12 - let selectedPluginId: PluginId | undefined = $state(); 13 - let selectedPlugin = $derived.by(() => { 14 - if (selectedPluginId !== undefined) { 15 - return plugins[selectedPluginId]; 14 + let components: Record<string, Component> = $state({}); 15 + let selectedPlugin: PluginInstance | undefined = $state(); 16 + let Menu = $derived(selectedPlugin ? components[selectedPlugin.meta.id] : undefined); 17 + 18 + onMount(async () => { 19 + for (const plugin of plugins) { 20 + if (!plugin.menu) continue; 21 + components[plugin.meta.id] = (await import(/* @vite-ignore */ plugin.menu)).default; 16 22 } 17 23 }); 18 24 </script> ··· 26 32 }} /> 27 33 28 34 <div class="plugins-container"> 29 - {#each Object.entries(plugins) as [id, plugin] (id)} 35 + {#each plugins as plugin (plugin.meta.id)} 30 36 <div class="group my-4"> 31 37 <Toggle 32 - {id} 38 + id={plugin.meta.id} 39 + text={plugin.meta.name} 40 + description={plugin.meta.description} 33 41 checked={plugin.toggle.state.toggle} 34 42 update={(toggled: boolean) => { 35 43 plugin.toggle.set({ toggle: toggled }); 36 44 }} 37 - text={plugin.name} 38 - description={plugin.description} 39 45 size="small"> 40 - {#if plugin.settings !== undefined} 46 + {#if plugin.settings} 41 47 <Button 42 - title={plugin.name + " Settings"} 43 - {id} 48 + title={plugin.meta.name + " Settings"} 49 + id={plugin.meta.id} 44 50 onclick={() => { 45 - selectedPluginId = id as PluginId; 51 + selectedPlugin = plugin; 46 52 showModal = true; 47 53 }}><Settings size={22} /></Button> 48 54 {/if} ··· 55 61 {#if selectedPlugin} 56 62 <Modal bind:showModal> 57 63 {#snippet header()} 58 - <h2 class="mb-4 text-xl">{selectedPlugin.name}</h2> 64 + {#if selectedPlugin} 65 + <h2 class="mb-4 text-xl">{selectedPlugin.meta.name}</h2> 66 + {/if} 59 67 {/snippet} 60 - {#if selectedPlugin.settings !== undefined} 61 - {#each Object.entries(selectedPlugin.settings) as [id, setting] (id)} 62 - {#if setting.type === "toggle"} 63 - <Toggle 64 - text={setting.name} 65 - description={setting.description} 66 - size="small" 67 - checked={setting.state.state.toggle} 68 - update={async (toggled) => { 69 - setting.state.set({ toggle: toggled }); 70 - }} 71 - {id} /> 72 - {:else if setting.type === "slider"} 73 - <Slider 74 - {id} 75 - update={(newValue) => { 76 - setting.state.update({ value: newValue }); 77 - }} 78 - {...setting.state.state} /> 79 - {/if} 80 - {/each} 81 - {/if} 68 + <Menu settings={selectedPlugin.settings} /> 82 69 </Modal> 83 70 {/if}
+104 -111
src/utils/plugin.ts
··· 1 + import { storage } from "#imports"; 1 2 import { hasChanged } from "."; 2 3 import { logger } from "./logger"; 3 - import type { PluginId, PluginSetting, Slider } from "./storage"; 4 - import { globalSettings, plugins, schoolboxUrls } from "./storage"; 4 + import type { Toggle } from "./storage"; 5 + import { globalSettings } from "./storage"; 6 + import { StorageState } from "./storage/state.svelte"; 5 7 6 - export async function definePlugin( 7 - pluginId: PluginId, 8 - injectCallback: (settings?: { 9 - toggle: Record<string, boolean>; 10 - slider: Record<string, Slider>; 11 - }) => Promise<void> | void, 12 - uninjectCallback: (settings?: { 13 - toggle: Record<string, boolean>; 14 - slider: Record<string, Slider>; 15 - }) => Promise<void> | void, 16 - elementsToWaitFor: string[] = [], 17 - ) { 18 - const plugin = await plugins[pluginId].toggle.get(); 19 - let injected = false; 20 - 21 - logger.info(`${plugins[pluginId].name}: ${plugin.toggle ? "enabled" : "disabled"}`); 8 + export class Plugin<T extends Record<string, unknown> | undefined = undefined> { 9 + private injected = false; 10 + public toggle: StorageState<Toggle>; 11 + public settings!: T; 12 + public menu: string | undefined; 22 13 23 - const settings = await globalSettings.get(); 24 - const urls = (await schoolboxUrls.get()).urls; 14 + constructor( 15 + public meta: { 16 + id: string; 17 + name: string; 18 + description: string; 19 + }, 20 + defaultToggle: boolean, 21 + settings: { 22 + config: Record<string, object>; 23 + menu: string; 24 + } | null, 25 + private injectCallback: (settings: T) => Promise<void> | void, 26 + private uninjectCallback: (settings: T) => Promise<void> | void, 27 + private elementsToWaitFor: string[] = [], 28 + ) { 29 + this.meta = meta; 30 + this.elementsToWaitFor = elementsToWaitFor; 31 + this.injectCallback = injectCallback; 32 + this.uninjectCallback = uninjectCallback; 33 + if (settings && settings.menu) this.menu = settings.menu; 25 34 26 - if (plugin && typeof window !== "undefined" && urls.includes(window.location.origin)) { 27 - const allElementsPresent = () => elementsToWaitFor.every((selector) => document.querySelector(selector) !== null); 35 + // init plugin storage 36 + this.toggle = new StorageState( 37 + storage.defineItem(`local:plugin-${meta.id}`, { 38 + fallback: { toggle: defaultToggle }, 39 + }), 40 + ); 41 + if (settings && settings.config) { 42 + this.settings = Object.fromEntries( 43 + Object.entries(settings.config).map(([key, value]) => [ 44 + key, 45 + new StorageState( 46 + storage.defineItem(`local:plugin-${meta.id}-${key}`, { 47 + fallback: value, 48 + }), 49 + ), 50 + ]), 51 + ) as T; 52 + } 53 + } 28 54 29 - const inject = async () => { 30 - if (injected) return; 31 - if (!allElementsPresent()) return; 32 - logger.info(`injecting plugin: ${plugins[pluginId].name}`); 33 - injectCallback(await getSettingsValues(plugins[pluginId]?.settings)); 34 - injected = true; 35 - }; 55 + async init() { 56 + logger.info(`init plugin: ${this.meta.name}`); 36 57 37 - const uninject = async () => { 38 - if (!injected) return; 39 - logger.info(`uninjecting plugin: ${plugins[pluginId].name}`); 40 - uninjectCallback(await getSettingsValues(plugins[pluginId]?.settings)); 41 - injected = false; 42 - }; 58 + if (await this.isEnabled()) { 59 + // wait for elements to be loaded 60 + if (this.elementsToWaitFor.length > 0) { 61 + // create an observer to wait for all elements to be loaded 62 + const observer = new MutationObserver((_mutations, observer) => { 63 + if (this.allElementsPresent()) { 64 + observer.disconnect(); 65 + this.inject(); 66 + } 67 + }); 68 + observer.observe(document.body, { childList: true, subtree: true }); 43 69 44 - const initWatchers = () => { 45 - // add watchers for injecting plugin 46 - globalSettings.watch(async (newValue, oldValue) => { 47 - if (hasChanged(newValue, oldValue, ["global", "plugins"])) { 48 - const plugin = await plugins[pluginId].toggle.get(); 49 - if (newValue.global && newValue.plugins && plugin.toggle) { 50 - inject(); 51 - } else { 52 - uninject(); 53 - } 70 + // check if elements are already present 71 + if (this.allElementsPresent()) { 72 + observer.disconnect(); 73 + this.inject(); 54 74 } 55 - }); 56 - plugins[pluginId].toggle.watch(async (newValue) => { 57 - const settings = await globalSettings.get(); 58 - if (newValue.toggle && settings.global && settings.plugins) { 59 - inject(); 60 - } else { 61 - uninject(); 62 - } 63 - }); 75 + } else { 76 + // no elements to wait for 77 + this.inject(); 78 + } 79 + } 64 80 65 - // reload plugin if settings have been updated 66 - if (plugins[pluginId].settings) { 67 - for (const setting of Object.values(plugins[pluginId].settings)) { 68 - setting.state.watch(async () => { 69 - uninject(); 70 - const settings = await globalSettings.get(); 71 - const toggle = await plugins[pluginId].toggle.get(); 72 - if (toggle && settings.global && settings.plugins) { 73 - inject(); 74 - } 75 - }); 76 - } 81 + // init watchers 82 + globalSettings.watch((newValue, oldValue) => { 83 + if (hasChanged(newValue, oldValue, ["global", "plugins"])) this.reload(); 84 + }); 85 + this.toggle.watch(this.reload.bind(this)); 86 + if (this.settings) { 87 + for (const setting of Object.values(this.settings)) { 88 + if (!(setting instanceof StorageState)) continue; 89 + setting.watch(this.reload.bind(this)); 77 90 } 78 - }; 91 + } 92 + } 79 93 80 - initWatchers(); 94 + private inject() { 95 + if (this.injected) return; 96 + if (!this.allElementsPresent()) return; 97 + logger.info(`injecting plugin: ${this.meta.name}`); 98 + this.injectCallback(this.settings); 99 + this.injected = true; 100 + } 81 101 82 - if (settings.global && settings.plugins && plugin.toggle) { 83 - const initObserver = () => { 84 - // wait for elements to be loaded 102 + private uninject() { 103 + if (!this.injected) return; 104 + logger.info(`uninjecting plugin: ${this.meta.name}`); 105 + this.uninjectCallback(this.settings); 106 + this.injected = false; 107 + } 85 108 86 - if (elementsToWaitFor.length > 0) { 87 - // create an observer to wait for all elements to be loaded 88 - const observer = new MutationObserver((_mutations, observer) => { 89 - if (allElementsPresent()) { 90 - observer.disconnect(); 91 - inject(); 92 - } 93 - }); 94 - observer.observe(document.body, { childList: true, subtree: true }); 109 + private async reload() { 110 + if (this.injected) this.uninject(); 111 + if (await this.isEnabled()) this.inject(); 112 + } 95 113 96 - // check if elements are already present 97 - if (allElementsPresent()) { 98 - observer.disconnect(); 99 - inject(); 100 - } 101 - } else { 102 - // no elements to wait for 103 - inject(); 104 - } 105 - }; 114 + private async isEnabled(): Promise<boolean> { 115 + const settings = await globalSettings.get(); 116 + const toggle = await this.toggle.get(); 106 117 107 - if (document.body) { 108 - initObserver(); 109 - } else { 110 - document.addEventListener("DOMContentLoaded", initObserver); 111 - } 112 - } 118 + return settings.global && settings.plugins && toggle.toggle; 113 119 } 114 - } 115 - 116 - async function getSettingsValues(settings?: Record<string, PluginSetting>) { 117 - if (!settings) return undefined; 118 120 119 - const result: { 120 - toggle: Record<string, boolean>; 121 - slider: Record<string, Slider>; 122 - } = { toggle: {}, slider: {} }; 123 - for (const [key, setting] of Object.entries(settings)) { 124 - if (setting.type === "toggle") { 125 - result.toggle[key] = (await setting.state.get()).toggle; 126 - } else if (setting.type === "slider") { 127 - result.slider[key] = await setting.state.get(); 128 - } 121 + private allElementsPresent() { 122 + return this.elementsToWaitFor.every((selector) => document.querySelector(selector) !== null); 129 123 } 130 - return result; 131 124 }
-1
src/utils/storage/index.ts
··· 1 1 export * from "./global"; 2 - export * from "./plugins"; 3 2 export * from "./snippets"; 4 3 export * from "./types";
-138
src/utils/storage/plugins.ts
··· 1 - import { storage } from "#imports"; 2 - import { StorageState } from "./state.svelte"; 3 - import type * as Types from "./types"; 4 - 5 - export const pluginConfig: Record<string, Types.PluginConfig> = { 6 - subheader: { 7 - name: "Subheader Revamp", 8 - description: "Adds a clock and current period info to the subheader.", 9 - default: true, 10 - settings: { 11 - openInNewTab: { 12 - type: "toggle", 13 - name: "Open links in new tab", 14 - description: "Whether to open the class link in a new tab.", 15 - default: { toggle: true }, 16 - }, 17 - }, 18 - }, 19 - scrollSegments: { 20 - name: "Scroll Segments", 21 - description: "Segments the Schoolbox page into scrollable sections.", 22 - default: true, 23 - }, 24 - scrollPeriod: { 25 - name: "Scroll Period", 26 - description: "Scrolls to the current period on the timetable.", 27 - default: true, 28 - settings: { 29 - resetCooldownOnMouseMove: { 30 - type: "toggle", 31 - name: "Reset on mouse move", 32 - description: "Whether to reset the scrolling cooldown when you move your mouse.", 33 - default: { toggle: true }, 34 - }, 35 - cooldownDuration: { 36 - type: "slider", 37 - name: "Cooldown duration (s)", 38 - description: "How long to wait before scrolling.", 39 - default: { min: 1, max: 60, value: 10 }, 40 - }, 41 - }, 42 - }, 43 - progressBar: { 44 - name: "Progress Bar", 45 - description: "Displays a progress bar below the timetable to show the time of the day.", 46 - default: true, 47 - }, 48 - modernIcons: { 49 - name: "Modern Icons", 50 - description: "Modernise the icons across Schoolbox.", 51 - default: true, 52 - settings: { 53 - filled: { 54 - type: "toggle", 55 - name: "Filled icons", 56 - description: "Whether the icons should be filled or outlined.", 57 - default: { toggle: true }, 58 - }, 59 - }, 60 - }, 61 - tabTitle: { 62 - name: "Better Tab Titles", 63 - description: "Improves the tab titles for easier navigation.", 64 - default: true, 65 - settings: { 66 - showSubjectPrefix: { 67 - type: "toggle", 68 - name: "Show subject prefix", 69 - description: `e.g. "ENG - VCE English 1 & 2" becomes "VCE English 1 & 2"`, 70 - default: { toggle: true }, 71 - }, 72 - }, 73 - }, 74 - homepageSwitcher: { 75 - name: "Homepage Switcher", 76 - description: "The logo will switch to existing Schoolbox homepage when available.", 77 - default: true, 78 - settings: { 79 - closeCurrentTab: { 80 - type: "toggle", 81 - name: "Close current tab", 82 - description: "When switching to another tab, close the current one.", 83 - default: { toggle: false }, 84 - }, 85 - }, 86 - }, 87 - } as const; 88 - 89 - export const plugins = buildPluginsFromConfig(pluginConfig); 90 - 91 - function buildPluginsFromConfig(config: Record<string, Types.PluginConfig>): Record<Types.PluginId, Types.PluginData> { 92 - const plugins: Partial<Record<Types.PluginId, Types.PluginData>> = {}; 93 - 94 - for (const [pluginId, pluginConfig] of Object.entries(config)) { 95 - const plugin: Types.PluginData = { 96 - name: pluginConfig.name, 97 - description: pluginConfig.description, 98 - toggle: new StorageState( 99 - storage.defineItem<Types.Toggle>(`local:plugin-${pluginId}`, { 100 - fallback: { toggle: pluginConfig.default }, 101 - }), 102 - ), 103 - }; 104 - 105 - // define settings 106 - if (pluginConfig.settings) { 107 - plugin.settings = {}; 108 - 109 - // iterate over each setting and create state 110 - for (const [settingId, settingConfig] of Object.entries(pluginConfig.settings)) { 111 - let state; 112 - if (settingConfig.type === "toggle") { 113 - state = new StorageState( 114 - storage.defineItem<Types.Toggle>(`local:plugin-${pluginId}-${settingId}`, { 115 - fallback: settingConfig.default, 116 - }), 117 - ); 118 - } else { 119 - state = new StorageState( 120 - storage.defineItem<Types.Slider>(`local:plugin-${pluginId}-${settingId}`, { 121 - fallback: settingConfig.default, 122 - }), 123 - ); 124 - } 125 - plugin.settings[settingId] = { 126 - state, 127 - type: settingConfig.type, 128 - name: settingConfig.name, 129 - description: settingConfig.description, 130 - } as Types.PluginSetting; 131 - } 132 - } 133 - 134 - plugins[pluginId as Types.PluginId] = plugin; 135 - } 136 - 137 - return plugins as Record<Types.PluginId, Types.PluginData>; 138 - }
+3 -36
src/utils/storage/types.ts
··· 1 - import type { pluginConfig } from "./plugins"; 2 1 import type { snippetConfig } from "./snippets"; 3 2 import type { StorageState } from "./state.svelte"; 4 3 ··· 47 46 toggle: boolean; 48 47 } 49 48 50 - // Common for plugins and snippets 49 + // common for plugins and snippets 51 50 export interface ItemInfo { 52 51 name: string; 53 52 description: string; 54 53 } 55 54 56 - // Plugins 57 - export type PluginId = keyof typeof pluginConfig; 58 - 55 + // plugins 59 56 export type Toggle = { toggle: boolean }; 60 57 61 58 export type Slider = { ··· 64 61 max: number; 65 62 }; 66 63 67 - export type PluginData = { 68 - toggle: StorageState<Toggle>; 69 - settings?: Record<string, PluginSetting>; 70 - } & ItemInfo; 71 - 72 - export type PluginConfig = { 73 - default: boolean; 74 - settings?: Record<string, PluginSettingConfig>; 75 - } & ItemInfo; 76 - 77 - export type PluginSetting = 78 - | ({ 79 - type: "toggle"; 80 - state: StorageState<Toggle>; 81 - } & ItemInfo) 82 - | ({ 83 - type: "slider"; 84 - state: StorageState<Slider>; 85 - } & ItemInfo); 86 - 87 - export type PluginSettingConfig = 88 - | ({ 89 - type: "toggle"; 90 - default: Toggle; 91 - } & ItemInfo) 92 - | ({ 93 - type: "slider"; 94 - default: Slider; 95 - } & ItemInfo); 96 - 97 - // Snippets 64 + // snippets 98 65 export type SnippetId = keyof typeof snippetConfig; 99 66 100 67 export type SnippetData = {