schoolbox web extension :)
0
fork

Configure Feed

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

Merge pull request #309 from schooltape/feat/hot-reload

feat: hot reload

authored by

willow and committed by
GitHub
830b7247 dfc6fcd9

+727 -405
+3 -5
src/entrypoints/background.ts
··· 46 46 }); 47 47 48 48 // update icon when toggle or update is changed 49 - globalSettings.storage.watch(() => { 50 - updateIcon(); 51 - }); 49 + globalSettings.watch(updateIcon); 52 50 53 51 // listen for messages 54 52 interface Message { ··· 138 136 if (new Date().getMonth() === 5) { 139 137 iconSuffix += "-ctp"; 140 138 } 141 - if ((await globalSettings.storage.getValue()).global === false) { 139 + if ((await globalSettings.get()).global === false) { 142 140 iconSuffix += "-disabled"; 143 141 } 144 - if ((await updated.storage.getValue()).icon === true) { 142 + if ((await updated.get()).icon === true) { 145 143 iconSuffix += "-badge"; 146 144 } 147 145
+11 -18
src/entrypoints/end.content.ts
··· 1 - import { browser, defineContentScript } from "#imports"; 1 + import { defineContentScript } from "#imports"; 2 2 import { EXCLUDE_MATCHES } from "@/utils/constants"; 3 3 import { logger } from "@/utils/logger"; 4 4 import { globalSettings, schoolboxUrls } from "@/utils/storage"; ··· 8 8 runAt: "document_end", 9 9 excludeMatches: EXCLUDE_MATCHES, 10 10 async main() { 11 - const settings = await globalSettings.storage.getValue(); 12 - const urls = (await schoolboxUrls.storage.getValue()).urls; 11 + const settings = await globalSettings.get(); 12 + const urls = (await schoolboxUrls.get()).urls; 13 13 14 - logger.info((await schoolboxUrls.storage.getValue()).urls); 14 + logger.info(urls); 15 15 16 16 if (!settings.global) return; 17 + 17 18 const footer = document.querySelector("#footer > ul"); 19 + 18 20 if (footer && footer.innerHTML.includes("Schoolbox")) { 19 - const footerListItem = document.createElement("li"); 20 - const footerLink = document.createElement("a"); 21 - footerLink.href = "https://github.com/schooltape/schooltape"; 22 - footerLink.textContent = `Schooltape v${browser.runtime.getManifest().version}`; 23 - footerListItem.appendChild(footerLink); 24 - footer.appendChild(footerListItem); 25 - 26 21 if (!urls.includes(window.location.origin)) { 27 - logger.info("[end.content.ts] URL not in settings, adding..."); 28 - if (!urls.includes(window.location.origin)) { 29 - urls.push(window.location.origin); 30 - await schoolboxUrls.storage.setValue({ urls }); 31 - // TODO: hot reload 32 - window.location.reload(); 33 - } 22 + logger.info(`URL ${window.location.origin} not in storage, adding...`); 23 + urls.push(window.location.origin); 24 + await schoolboxUrls.set({ urls }); 25 + // TODO: hot reload 26 + window.location.reload(); 34 27 } 35 28 } 36 29 },
+36 -12
src/entrypoints/plugins/homepageSwitcher.ts
··· 1 1 import { browser } from "#imports"; 2 2 import { definePlugin } from "@/utils/plugin"; 3 3 4 + let logos: HTMLAnchorElement[] | null = null; 5 + const controller = new AbortController(); 6 + 4 7 export default function init() { 5 8 definePlugin( 6 9 "homepageSwitcher", 7 10 (settings) => { 8 - const logos = Array.from(document.getElementsByClassName("logo")) as HTMLAnchorElement[]; 9 - logos.forEach((logo) => { 10 - logo.addEventListener("click", async function (e) { 11 - if (window.location.pathname === "/") return; 12 - e.preventDefault(); 13 - const tab = logos[0].href; 14 - if (settings?.toggle.closeCurrentTab === true) { 15 - window.close(); 16 - } 17 - browser.runtime.sendMessage({ toTab: tab }); 18 - }); 19 - }); 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 + for (const logo of logos) { 18 + logo.addEventListener( 19 + "click", 20 + (e) => { 21 + if (window.location.pathname === "/") return; 22 + 23 + e.preventDefault(); 24 + 25 + if (logos) { 26 + const tab = logos[0].href; 27 + if (closeCurrentTab) window.close(); // TODO: Scripts may only close windows that were opened by a script. 28 + browser.runtime.sendMessage({ toTab: tab }); 29 + } 30 + }, 31 + { 32 + signal: controller.signal, 33 + }, 34 + ); 35 + } 36 + }, 37 + () => { 38 + if (logos === null) return; 39 + 40 + // remove event listeners 41 + controller.abort(); 42 + 43 + logos = null; 20 44 }, 21 45 [".logo"], 22 46 );
+86 -64
src/entrypoints/plugins/modernIcons/index.ts
··· 1 - import { injectStyles } from "@/utils"; 1 + import { 2 + dataAttr, 3 + injectInlineStyles, 4 + injectStylesheet, 5 + setDataAttr, 6 + uninjectInlineStyles, 7 + uninjectStylesheet, 8 + } from "@/utils"; 2 9 import { definePlugin } from "@/utils/plugin"; 3 10 import styleText from "./styles.css?inline"; 4 11 12 + const ID = "modernIcons"; 13 + const PLUGIN_ID = `plugin-${ID}`; 14 + 5 15 export default function init() { 6 16 definePlugin( 7 - "modernIcons", 17 + ID, 8 18 async (settings) => { 9 - // [className, iconName] (material icons) 10 - const icons = { 11 - "icon-teacher": "school", 12 - "icon-due-work": "inventory_2", 13 - "icon-task": "inventory", 14 - "icon-timetable": "schedule", 15 - "icon-calendar": "calendar_month", 16 - "icon-news": "newspaper", 17 - "icon-email": "email", 18 - "icon-wolfram-alpha": "web", 19 - "icon-comment": "translate", 20 - "icon-canvas-lms": "medical_services", 21 - "icon-video": "videocam", 22 - "icon-office-365": "dvr", 23 - "icon-google-drive": "drive_export", 24 - "icon-help": "help", 25 - "icon-podcast": "music_note", 26 - "icon-music": "music_note", 27 - "icon-staff-students": "account_circle", 28 - "icon-settings": "settings", 29 - "icon-logout": "logout", 30 - "icon-course": "class", 31 - "icon-reply": "reply", 32 - "icon-approve": "check_circle", 33 - "icon-forms": "check_box", 34 - "icon-group": "group", 35 - "icon-info": "info", 36 - "icon-resource-booking": "photo_camera", 37 - "icon-files": "description", 38 - "icon-schoolbox": "language", 39 - "icon-user": "person", 40 - "icon-cloudy": "cloud", 41 - "icon-eportfolio": "work", 42 - "icon-open": "door_open", 43 - }; 44 - 45 - function insertIcon(className: string, iconName: string, fill: boolean) { 46 - const selectors = [`nav.tab-bar .top-menu .${className}`, `#overflow-nav .${className}`]; 47 - selectors.forEach((selector) => { 48 - const icon = document.querySelector(selector); 49 - // Check if the icon already exists 50 - if (icon && !icon.querySelector(".material-symbols-rounded")) { 51 - // logger.info(`Inserting icon for ${className} at ${selector}`); 52 - const iconElement = document.createElement("i"); 53 - iconElement.innerHTML = iconName; 54 - iconElement.classList.add("material-symbols-rounded"); 55 - iconElement.style.fontVariationSettings = `"FILL" ${fill ? "1" : "0"}`; 56 - icon.insertBefore(iconElement, icon.firstChild); 57 - } 58 - }); 59 - } 60 - 61 19 const iconNames = [...new Set(Object.values(icons))].sort(); 62 20 const fontUrl = `https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:FILL@0..1&icon_names=${iconNames.join(",")}`; 63 - // logger.info(fontUrl); 64 21 65 22 // inject font face 66 - const style = document.createElement("link"); 67 - style.rel = "stylesheet"; 68 - style.href = fontUrl; 69 - style.type = "text/css"; 70 - document.head.appendChild(style); 23 + injectStylesheet(fontUrl, PLUGIN_ID); 71 24 72 - injectStyles(styleText); 25 + // inject icon styling 26 + injectInlineStyles(styleText, PLUGIN_ID); 73 27 74 - for (const [className, iconName] of Object.entries(icons)) { 75 - insertIcon(className, iconName, settings?.toggle.filled ?? false); 76 - } 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(); 77 36 }, 78 37 ["nav.tab-bar .top-menu", "#overflow-nav"], 79 38 ); 80 39 } 40 + 41 + // [className, iconName] (material icons) 42 + const icons = { 43 + "icon-teacher": "school", 44 + "icon-due-work": "inventory_2", 45 + "icon-task": "inventory", 46 + "icon-timetable": "schedule", 47 + "icon-calendar": "calendar_month", 48 + "icon-news": "newspaper", 49 + "icon-email": "email", 50 + "icon-wolfram-alpha": "web", 51 + "icon-comment": "translate", 52 + "icon-canvas-lms": "medical_services", 53 + "icon-video": "videocam", 54 + "icon-office-365": "dvr", 55 + "icon-google-drive": "drive_export", 56 + "icon-help": "help", 57 + "icon-podcast": "music_note", 58 + "icon-music": "music_note", 59 + "icon-staff-students": "account_circle", 60 + "icon-settings": "settings", 61 + "icon-logout": "logout", 62 + "icon-course": "class", 63 + "icon-reply": "reply", 64 + "icon-approve": "check_circle", 65 + "icon-forms": "check_box", 66 + "icon-group": "group", 67 + "icon-info": "info", 68 + "icon-resource-booking": "photo_camera", 69 + "icon-files": "description", 70 + "icon-schoolbox": "language", 71 + "icon-user": "person", 72 + "icon-cloudy": "cloud", 73 + "icon-eportfolio": "work", 74 + "icon-open": "door_open", 75 + }; 76 + 77 + function injectIcons(icons: Record<string, string>, filled: boolean) { 78 + for (const [className, iconName] of Object.entries(icons)) { 79 + const selectors = [`nav.tab-bar .top-menu .${className}`, `#overflow-nav .${className}`]; 80 + 81 + for (const selector of selectors) { 82 + const icon = document.querySelector(selector); 83 + // check if the icon already exists 84 + if (icon && !icon.querySelector(".material-symbols-rounded")) { 85 + // logger.info(`inserting icon for ${className} at ${selector}`); 86 + const iconElement = document.createElement("i"); 87 + iconElement.innerHTML = iconName; 88 + iconElement.classList.add("material-symbols-rounded"); 89 + iconElement.style.fontVariationSettings = `"FILL" ${filled ? "1" : "0"}`; 90 + setDataAttr(iconElement, `${PLUGIN_ID}-icon`); 91 + icon.insertBefore(iconElement, icon.firstChild); 92 + } 93 + } 94 + } 95 + } 96 + 97 + function uninjectIcons() { 98 + const icons = document.querySelectorAll(dataAttr(`${PLUGIN_ID}-icon`)); 99 + for (const icon of icons) { 100 + icon.parentNode?.removeChild(icon); 101 + } 102 + }
+23 -7
src/entrypoints/plugins/progressBar/index.ts
··· 1 - import { injectStyles } from "@/utils"; 1 + import { dataAttr, injectInlineStyles, setDataAttr, uninjectInlineStyles } from "@/utils"; 2 2 import type { Period } from "@/utils/periodUtils"; 3 3 import { getListOfPeriods } from "@/utils/periodUtils"; 4 4 import { definePlugin } from "@/utils/plugin"; 5 5 import styleText from "./styles.css?inline"; 6 6 7 + const ID = "progressBar"; 8 + const PLUGIN_ID = `plugin-${ID}`; 9 + 7 10 export default function init() { 8 11 definePlugin( 9 - "progressBar", 12 + ID, 10 13 () => { 11 14 if (window.location.pathname === "/" && document.querySelector(".timetable")) { 12 15 const periodList = getListOfPeriods(); ··· 15 18 progressRow.classList.add("progress-container"); 16 19 document.querySelector(".timetable > thead")?.insertAdjacentElement("beforeend", progressRow); 17 20 18 - injectStyles(styleText); 19 - insertProgressBars(periodList, progressRow); 21 + injectInlineStyles(styleText, PLUGIN_ID); 22 + injectProgressBars(periodList, progressRow); 23 + 24 + setDataAttr(progressRow, `${PLUGIN_ID}-row`); 20 25 } 21 26 }, 27 + () => { 28 + uninjectInlineStyles(PLUGIN_ID); 29 + uninjectProgressBars(); 30 + }, 22 31 [".timetable"], 23 32 ); 24 33 } 25 34 26 - function insertProgressBars(periodList: Period[], container: HTMLElement) { 27 - periodList.forEach((period) => { 35 + function injectProgressBars(periodList: Period[], container: HTMLElement) { 36 + if (document.querySelector(dataAttr(`${PLUGIN_ID}-row`))) return; 37 + 38 + for (const period of periodList) { 28 39 const td = document.createElement("td"); 29 40 const progressBar = document.createElement("progress"); 30 41 const progress = period.getProgress(); ··· 44 55 45 56 td.appendChild(progressBar); 46 57 container.appendChild(td); 47 - }); 58 + } 59 + } 60 + 61 + function uninjectProgressBars() { 62 + const row = document.querySelector(dataAttr(`${PLUGIN_ID}-row`)); 63 + row?.parentElement?.removeChild(row); 48 64 }
+37 -29
src/entrypoints/plugins/scrollPeriod.ts
··· 1 1 import { getCurrentPeriod } from "@/utils/periodUtils"; 2 2 import { definePlugin } from "@/utils/plugin"; 3 3 4 + let interval: NodeJS.Timeout | null = null; 5 + const controller = new AbortController(); 6 + 4 7 export default function init() { 5 8 definePlugin( 6 9 "scrollPeriod", 7 10 async (settings) => { 8 11 const timetable = document.querySelector("[data-timetable-container] div.scrollable"); 9 12 10 - if (window.location.pathname === "/" && document.getElementsByClassName("timetable")[0]) { 11 - updateScrollbar(); 13 + if (window.location.pathname === "/" && timetable) { 14 + updateScrollbar(timetable); 12 15 13 16 const cooldownDuration = settings?.slider.cooldownDuration; 14 17 const resetCooldownOnMouseMove = settings?.toggle.resetCooldownOnMouseMove; 15 18 16 - let interval: string | number | NodeJS.Timeout | undefined; 17 - function start() { 18 - interval = setInterval(updateScrollbar, (cooldownDuration?.value || 10) * 1000); 19 - } 20 - function reset() { 21 - if (interval) { 22 - clearInterval(interval); 23 - start(); 24 - } 25 - } 19 + const setUpdateInterval = () => { 20 + interval = setInterval(() => updateScrollbar(timetable), (cooldownDuration?.value || 10) * 1000); 21 + }; 26 22 27 - start(); 23 + setUpdateInterval(); 28 24 29 25 if (resetCooldownOnMouseMove === true) { 30 - document.addEventListener("mousemove", reset); 31 - } 32 - } 33 - 34 - function updateScrollbar() { 35 - const currentPeriod = getCurrentPeriod(); 36 - if (currentPeriod && currentPeriod.index && timetable) { 37 - const period = document.querySelector( 38 - `.timetable thead tr th:nth-child(${currentPeriod.index})`, 39 - ) as HTMLElement; 40 - if (period) { 41 - timetable.scroll({ 42 - left: period.offsetLeft - 55, // adjusted for alignment 43 - behavior: "smooth", // or 'auto' for instant scroll 44 - }); 45 - } 26 + document.addEventListener( 27 + "mousemove", 28 + () => { 29 + if (interval) { 30 + clearInterval(interval); 31 + setUpdateInterval(); 32 + } 33 + }, 34 + { signal: controller.signal }, 35 + ); 46 36 } 47 37 } 48 38 }, 39 + () => { 40 + controller.abort(); 41 + if (interval) clearInterval(interval); 42 + interval = null; 43 + }, 49 44 [".timetable"], 50 45 ); 51 46 } 47 + 48 + function updateScrollbar(timetable: Element) { 49 + const currentPeriod = getCurrentPeriod(); 50 + if (currentPeriod && currentPeriod.index && timetable) { 51 + const period = document.querySelector(`.timetable thead tr th:nth-child(${currentPeriod.index})`) as HTMLElement; 52 + if (period) { 53 + timetable.scroll({ 54 + left: period.offsetLeft - 55, // adjusted for alignment 55 + behavior: "smooth", // or 'auto' for instant scroll 56 + }); 57 + } 58 + } 59 + }
+33 -6
src/entrypoints/plugins/scrollSegments/index.ts
··· 1 - import { injectStyles } from "@/utils"; 1 + import { dataAttr, injectInlineStyles, setDataAttr, uninjectInlineStyles } from "@/utils"; 2 2 import { definePlugin } from "@/utils/plugin"; 3 3 import styleText from "./styles.css?inline"; 4 + 5 + const ID = "scrollSegments"; 6 + const PLUGIN_ID = `plugin-${ID}`; 4 7 5 8 export default function init() { 6 9 definePlugin( 7 - "scrollSegments", 10 + ID, 8 11 () => { 9 - const content = document.getElementById("content"); 10 - const footer = document.getElementById("footer"); 12 + const footerCopy = document.querySelector(dataAttr(PLUGIN_ID)); 13 + if (footerCopy) return; 14 + 15 + // scroll to top to avoid hot reload bug 16 + window.scrollTo({ 17 + top: 0, 18 + behavior: "instant", 19 + }); 20 + 21 + const content = document.querySelector("#content"); 22 + const footer = document.querySelector<HTMLDivElement>("#footer"); 23 + 24 + // add copy of footer to content 11 25 if (content && footer) { 12 - content.appendChild(footer); 26 + const clone = footer.cloneNode(true) as HTMLDivElement; 27 + setDataAttr(clone, PLUGIN_ID); 28 + content.appendChild(clone); 13 29 } 14 - injectStyles(styleText); 30 + 31 + injectInlineStyles(styleText, PLUGIN_ID); 32 + }, 33 + () => { 34 + const footerCopy = document.querySelector(dataAttr(PLUGIN_ID)); 35 + if (!footerCopy) return; 36 + 37 + // remove copy of footer from content 38 + const content = document.querySelector("#content"); 39 + content?.removeChild(footerCopy); 40 + 41 + uninjectInlineStyles(PLUGIN_ID); 15 42 }, 16 43 ["#content", "#footer"], 17 44 );
-113
src/entrypoints/plugins/subheader.ts
··· 1 - import { getCurrentPeriod } from "@/utils/periodUtils"; 2 - import { definePlugin } from "@/utils/plugin"; 3 - 4 - export default function init() { 5 - definePlugin( 6 - "subheader", 7 - (settings) => { 8 - const style = document.createElement("style"); 9 - style.classList = "schooltape"; 10 - style.innerHTML = ` 11 - .subheader span:not(:last-child):not(.period:empty)::after { 12 - content: " | "; 13 - font-weight: bold; 14 - } 15 - .subheader a { 16 - color: inherit; 17 - } 18 - `; 19 - 20 - document.head.appendChild(style); 21 - 22 - if (window.location.pathname === "/" && document.getElementsByClassName("timetable")[0]) { 23 - createSubheader(); 24 - setInterval(updatePeriodSpan, 5000); 25 - setInterval(updateClockSpan, 1000); 26 - setInterval(updateDateSpan, 60000); 27 - } 28 - 29 - function createSubheader() { 30 - const subheader = document.querySelector("h2.subheader"); 31 - if (!subheader) return; 32 - // TODO: Refactor to support hot reload/uninjection 33 - // delete all children of the subheader 34 - while (subheader.firstChild) { 35 - subheader.removeChild(subheader.firstChild); 36 - } 37 - const span = document.createElement("span"); 38 - span.classList.add("schooltape"); 39 - subheader.appendChild(span); 40 - 41 - updatePeriodSpan(); 42 - updateClockSpan(); 43 - updateDateSpan(); 44 - } 45 - 46 - async function updatePeriodSpan() { 47 - let periodSpan = document.querySelector(".subheader .period"); 48 - if (!periodSpan) { 49 - const subheader = document.querySelector(".subheader .schooltape"); 50 - if (!subheader) return; 51 - periodSpan = document.createElement("span"); 52 - periodSpan.classList.add("period"); 53 - subheader.appendChild(periodSpan); 54 - } 55 - periodSpan.textContent = ""; 56 - 57 - const period = getCurrentPeriod(); 58 - if (period) { 59 - const name = period.data.name || period.header.name; 60 - const room = period.data.room ? ` (${period.data.room})` : ""; 61 - let periodLink = periodSpan.querySelector("a"); 62 - if (period.data.name && period.data.link) { 63 - // if there's period data 64 - if (!periodLink) { 65 - periodLink = document.createElement("a"); 66 - 67 - periodLink.target = settings?.toggle.openInNewTab ? "_blank" : "_self"; 68 - periodSpan.appendChild(periodLink); 69 - } 70 - periodLink.href = period.data.link; 71 - periodLink.textContent = `${name}${room}`; 72 - } else { 73 - // if there's only the header 74 - periodSpan.textContent = `${name}${room}`; 75 - if (periodLink) { 76 - periodSpan.removeChild(periodLink); 77 - } 78 - } 79 - } 80 - } 81 - 82 - function updateClockSpan() { 83 - let clockSpan = document.querySelector(".subheader .clock"); 84 - if (!clockSpan) { 85 - const subheader = document.querySelector(".subheader .schooltape"); 86 - if (!subheader) return; 87 - clockSpan = document.createElement("span"); 88 - clockSpan.classList.add("clock"); 89 - subheader.appendChild(clockSpan); 90 - } 91 - const date = new Date(); 92 - clockSpan.textContent = date.toLocaleTimeString([], { 93 - hour: "2-digit", 94 - minute: "2-digit", 95 - }); 96 - } 97 - 98 - function updateDateSpan() { 99 - let dateSpan = document.querySelector(".subheader .date"); 100 - if (!dateSpan) { 101 - const subheader = document.querySelector(".subheader .schooltape"); 102 - if (!subheader) return; 103 - dateSpan = document.createElement("span"); 104 - dateSpan.classList.add("date"); 105 - subheader.appendChild(dateSpan); 106 - } 107 - const date = new Date(); 108 - dateSpan.textContent = date.toDateString(); 109 - } 110 - }, 111 - [".subheader"], 112 - ); 113 - }
+158
src/entrypoints/plugins/subheader/index.ts
··· 1 + import { getCurrentPeriod } from "@/utils/periodUtils"; 2 + import { definePlugin } from "@/utils/plugin"; 3 + import styleText from "./styles.css?inline"; 4 + import { dataAttr, injectInlineStyles, setDataAttr, uninjectInlineStyles } from "@/utils"; 5 + 6 + const ID = "subheader"; 7 + const PLUGIN_ID = `plugin-${ID}`; 8 + 9 + let intervals: NodeJS.Timeout[] = []; 10 + let oldChildren: ChildNode[] = []; 11 + let subheader: HTMLHeadingElement | null = null; 12 + 13 + export default function init() { 14 + definePlugin( 15 + "subheader", 16 + (settings) => { 17 + const openInNewTab = settings?.toggle.openInNewTab ?? false; 18 + injectSubheader(openInNewTab); 19 + }, 20 + uninjectSubheader, 21 + [".subheader", ".timetable"], 22 + ); 23 + } 24 + 25 + function injectSubheader(openInNewTab: boolean) { 26 + // abort if plugin is injected 27 + if (subheader !== null) return; 28 + 29 + // abort if not on homepage 30 + if (window.location.pathname !== "/") return; 31 + 32 + subheader = document.querySelector("h2.subheader"); 33 + if (!subheader) return; 34 + 35 + // inject subheader styling 36 + injectInlineStyles(styleText, PLUGIN_ID); 37 + 38 + // delete all children of the subheader 39 + while (subheader.firstChild) { 40 + oldChildren.push(subheader.removeChild(subheader.firstChild)); 41 + } 42 + 43 + updatePeriodSpan(openInNewTab); 44 + updateClockSpan(); 45 + updateDateSpan(); 46 + 47 + intervals = [ 48 + setInterval(() => updatePeriodSpan(openInNewTab), 5000), 49 + setInterval(updateClockSpan, 1000), 50 + setInterval(updateDateSpan, 60000), 51 + ]; 52 + } 53 + 54 + function uninjectSubheader() { 55 + // abort if plugin is not injected 56 + if (subheader === null) return; 57 + 58 + // abort if not on homepage 59 + if (window.location.pathname !== "/") return; 60 + 61 + // stop updating the subheader 62 + intervals.forEach((interval) => clearInterval(interval)); 63 + 64 + for (const child of oldChildren) { 65 + // remove new children 66 + while (subheader.firstChild) { 67 + subheader.removeChild(subheader.firstChild); 68 + } 69 + 70 + // restore old children 71 + subheader.appendChild(child); 72 + } 73 + 74 + // uninject subheader styling 75 + uninjectInlineStyles(PLUGIN_ID); 76 + 77 + // reset variables 78 + intervals = []; 79 + oldChildren = []; 80 + subheader = null; 81 + } 82 + 83 + async function updatePeriodSpan(openInNewTab: boolean) { 84 + if (!subheader) return; 85 + 86 + const periodId = `${PLUGIN_ID}-period`; 87 + let periodSpan = document.querySelector<HTMLSpanElement>(`.subheader ${dataAttr(periodId)}`); 88 + 89 + if (!periodSpan) { 90 + periodSpan = document.createElement("span"); 91 + setDataAttr(periodSpan, periodId); 92 + subheader.appendChild(periodSpan); 93 + } 94 + 95 + periodSpan.textContent = ""; 96 + 97 + // set period span content 98 + const period = getCurrentPeriod(); 99 + if (period) { 100 + const name = period.data.name || period.header.name; 101 + const room = period.data.room ? ` (${period.data.room})` : ""; 102 + let periodLink = periodSpan.querySelector("a"); 103 + if (period.data.name && period.data.link) { 104 + // if there's period data 105 + if (!periodLink) { 106 + periodLink = document.createElement("a"); 107 + 108 + periodLink.target = openInNewTab ? "_blank" : "_self"; 109 + periodSpan.appendChild(periodLink); 110 + } 111 + periodLink.href = period.data.link; 112 + periodLink.textContent = `${name}${room}`; 113 + } else { 114 + // if there's only the header 115 + periodSpan.textContent = `${name}${room}`; 116 + if (periodLink) { 117 + periodSpan.removeChild(periodLink); 118 + } 119 + } 120 + } 121 + } 122 + 123 + function updateClockSpan() { 124 + if (!subheader) return; 125 + 126 + const clockId = `${PLUGIN_ID}-clock`; 127 + let clockSpan = document.querySelector<HTMLSpanElement>(`.subheader ${dataAttr(clockId)}`); 128 + 129 + if (!clockSpan) { 130 + clockSpan = document.createElement("span"); 131 + setDataAttr(clockSpan, clockId); 132 + subheader.appendChild(clockSpan); 133 + } 134 + 135 + // set clock span content 136 + const date = new Date(); 137 + clockSpan.textContent = date.toLocaleTimeString([], { 138 + hour: "2-digit", 139 + minute: "2-digit", 140 + }); 141 + } 142 + 143 + function updateDateSpan() { 144 + if (!subheader) return; 145 + 146 + const dateId = `${PLUGIN_ID}-date`; 147 + let dateSpan = document.querySelector<HTMLSpanElement>(`.subheader ${dataAttr(dateId)}`); 148 + 149 + if (!dateSpan) { 150 + dateSpan = document.createElement("span"); 151 + setDataAttr(dateSpan, dateId); 152 + subheader.appendChild(dateSpan); 153 + } 154 + 155 + // set date span content 156 + const date = new Date(); 157 + dateSpan.textContent = date.toDateString(); 158 + }
+7
src/entrypoints/plugins/subheader/styles.css
··· 1 + .subheader span:not(:last-child):not([data-schooltape="plugin-subheader-period"]:empty)::after { 2 + content: " | "; 3 + font-weight: bold; 4 + } 5 + .subheader a { 6 + color: inherit; 7 + }
+17 -1
src/entrypoints/plugins/tabTitle.ts
··· 1 1 import { definePlugin } from "@/utils/plugin"; 2 2 3 + const ID = "tabTitle"; 4 + let originalTitle: string | null = null; 5 + 3 6 export default function init() { 4 7 definePlugin( 5 - "tabTitle", 8 + ID, 6 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 + 7 16 const path = window.location.pathname; 8 17 const titleMap: { [key: string]: string } = { 9 18 "/": "Homepage", ··· 43 52 document.title = document.getElementsByTagName("h1")[0].innerText; 44 53 } 45 54 } 55 + }, 56 + () => { 57 + // if not injected, abort 58 + if (!originalTitle) return; 59 + 60 + document.title = originalTitle; 61 + originalTitle = null; 46 62 }, 47 63 ["h1"], 48 64 );
+4 -26
src/entrypoints/popup/App.svelte
··· 1 1 <script lang="ts"> 2 2 import { flavors } from "@catppuccin/palette"; 3 - import { globalSettings, needsRefresh, schoolboxUrls, updated } from "@/utils/storage"; 4 - import { RotateCw } from "@lucide/svelte"; 5 - import { logger } from "@/utils/logger"; 3 + import { globalSettings, updated } from "@/utils/storage"; 6 4 import { browser, onMount } from "#imports"; 7 5 8 6 import Router from "svelte-spa-router"; ··· 11 9 import Plugins from "./routes/Plugins.svelte"; 12 10 import Themes from "./routes/Themes.svelte"; 13 11 import Snippets from "./routes/Snippets.svelte"; 14 - import Banner from "./components/Banner.svelte"; 15 12 16 13 const routes = { 17 14 "/": Home, ··· 20 17 "/snippets": Snippets, 21 18 }; 22 19 23 - async function refreshSchoolboxURLs() { 24 - logger.info("[App.svelte] Refreshing all Schoolbox URLs"); 25 - const urls = (await schoolboxUrls.storage.getValue()).urls.map((url) => url.replace(/^https:\/\//, "*://") + "/*"); 26 - const tabs = await browser.tabs.query({ url: urls }); 27 - tabs.forEach((tab) => { 28 - if (tab.id) { 29 - browser.tabs.reload(tab.id); 30 - } 31 - }); 32 - } 33 - 34 20 function getAccentRgb(accent: string, flavour: string) { 35 21 // eslint-disable-next-line @typescript-eslint/no-explicit-any 36 22 let x = (flavors as any)[flavour].colors[accent].rgb; ··· 40 26 let accentRgb = $derived(getAccentRgb(globalSettings.state.themeAccent, globalSettings.state.themeFlavour)); 41 27 42 28 onMount(() => { 43 - updated.set({ icon: false }); 29 + updated.update({ icon: false }); 44 30 browser.runtime.sendMessage({ updateIcon: true }); 45 31 }); 46 32 </script> 47 33 48 34 <main 49 - class="flex flex-col items-center bg-ctp-base p-6 {globalSettings.state.themeFlavour}" 35 + class="bg-ctp-base flex flex-col items-center p-6 {globalSettings.state.themeFlavour}" 50 36 style="--ctp-accent: {accentRgb}"> 51 - <nav class="mb-4 flex rounded-xl px-4 py-2 text-ctp-text" id="navbar"> 37 + <nav class="text-ctp-text mb-4 flex rounded-xl px-4 py-2" id="navbar"> 52 38 <a href="#/" class="navbutton-left" use:active={{ className: "active" }}>Settings</a> 53 39 <a href="#/plugins" class="navbutton-center" use:active={{ className: "active" }}>Plugins</a> 54 40 <a href="#/themes" class="navbutton-center" use:active={{ className: "active" }}>Themes</a> 55 41 <a href="#/snippets" class="navbutton-right" use:active={{ className: "active" }}>Snippets</a> 56 42 </nav> 57 - 58 - <Banner 59 - message="Click here to apply changes" 60 - visible={needsRefresh.state} 61 - onclick={() => { 62 - needsRefresh.storage.setValue(false); 63 - refreshSchoolboxURLs(); 64 - }}><RotateCw /></Banner> 65 43 66 44 <Router {routes} /> 67 45 </main>
+5 -5
src/entrypoints/popup/components/Footer.svelte
··· 18 18 <footer class="mt-4 flex min-w-full justify-around p-4"> 19 19 <span class="relative inline-flex"> 20 20 <Button 21 - onclick={async () => { 22 - await updated.set({ changelog: false }); 21 + onclick={() => { 22 + updated.update({ changelog: false }); 23 23 24 24 browser.tabs.create({ 25 25 url: `https://github.com/schooltape/schooltape/releases/tag/v${version}`, ··· 32 32 <!-- show ripple badge if the extension has been updated (unread release notes) --> 33 33 {#if updated.state.changelog} 34 34 <span class="absolute top-0 right-0 -mt-1 -mr-1 flex size-3"> 35 - <span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-ctp-blue opacity-75"></span> 36 - <span class="relative inline-flex size-3 rounded-full bg-ctp-blue"></span> 35 + <span class="bg-ctp-blue absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span> 36 + <span class="bg-ctp-blue relative inline-flex size-3 rounded-full"></span> 37 37 </span> 38 38 {/if} 39 39 </Button> ··· 52 52 onclick={() => { 53 53 window.open("https://discord.gg/rZxtGJ98BE", "_blank"); 54 54 }} 55 - ><svg class="h-[22px] fill-ctp-text" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" 55 + ><svg class="fill-ctp-text h-[22px]" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" 56 56 ><title>Discord</title><path 57 57 d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" /></svg 58 58 ></Button>
+2 -2
src/entrypoints/popup/routes/Home.svelte
··· 14 14 <button 15 15 class="bg-(--ctp-accent) hover:opacity-75 {globalSettings.state.global ? '' : 'opacity-60'}" 16 16 id="toggle" 17 - onclick={() => { 18 - globalSettings.set({ global: !globalSettings.get().global }); 17 + onclick={async () => { 18 + globalSettings.update({ global: !(await globalSettings.get()).global }); 19 19 }} 20 20 >{globalSettings.state.global ? "enabled" : "disabled"} 21 21 </button>
+3 -3
src/entrypoints/popup/routes/Plugins.svelte
··· 22 22 title="Plugins" 23 23 checked={globalSettings.state.plugins} 24 24 update={(toggled: boolean) => { 25 - globalSettings.set({ plugins: toggled }); 25 + globalSettings.update({ plugins: toggled }); 26 26 }} /> 27 27 28 28 <div class="plugins-container"> ··· 72 72 {:else if setting.type === "slider"} 73 73 <Slider 74 74 {id} 75 - update={async (newValue) => { 76 - setting.state.set({ value: newValue }); 75 + update={(newValue) => { 76 + setting.state.update({ value: newValue }); 77 77 }} 78 78 {...setting.state.state} /> 79 79 {/if}
+10 -10
src/entrypoints/popup/routes/Snippets.svelte
··· 24 24 let sections = snippetURL.split("/"); 25 25 let key = sections[sections.length - 1].split(".")[0]; 26 26 27 - let settings = await globalSettings.storage.getValue(); 27 + let settings = await globalSettings.get(); 28 28 settings.userSnippets[key] = { 29 29 author: sections[3], 30 30 name: getMatch(data, /\/\*\s*name:\s*(.*?)\s*\*\//) || key, ··· 32 32 url: snippetURL, 33 33 toggle: true, 34 34 }; 35 - await globalSettings.storage.setValue(settings); 35 + await globalSettings.set(settings); 36 36 } 37 37 </script> 38 38 ··· 41 41 title="Snippets" 42 42 checked={globalSettings.state.snippets} 43 43 update={(toggled: boolean) => { 44 - globalSettings.set({ snippets: toggled }); 44 + globalSettings.update({ snippets: toggled }); 45 45 }} /> 46 46 47 47 <div class="snippets-container w-full"> ··· 60 60 {/each} 61 61 </div> 62 62 <div class="w-full"> 63 - <h3 class="my-4 text-ctp-text">User Snippets</h3> 64 - <p class="mb-4 text-ctp-overlay2"> 63 + <h3 class="text-ctp-text my-4">User Snippets</h3> 64 + <p class="text-ctp-overlay2 mb-4"> 65 65 To learn how to make your own snippets, please read the 66 66 <a 67 67 class="text-ctp-blue hover:underline" ··· 80 80 {id} 81 81 checked={snippet.toggle} 82 82 update={async (toggled: boolean) => { 83 - let settings = await globalSettings.storage.getValue(); 83 + let settings = await globalSettings.get(); 84 84 settings.userSnippets[id].toggle = toggled; 85 - await globalSettings.storage.setValue(settings); 85 + await globalSettings.set(settings); 86 86 }} 87 87 text={snippet.name} 88 88 description={snippet.description} ··· 90 90 <button 91 91 class="xsmall hover:bg-ctp-red hover:text-ctp-mantle" 92 92 onclick={async () => { 93 - let settings = await globalSettings.storage.getValue(); 93 + let settings = await globalSettings.get(); 94 94 delete settings.userSnippets[id]; 95 - await globalSettings.storage.setValue(settings); 95 + await globalSettings.set(settings); 96 96 }}>Remove</button> 97 97 <a href={snippet.url} target="_blank" 98 - ><button class="xsmall hover:bg-(--ctp-accent) hover:text-ctp-mantle">Gist</button></a> 98 + ><button class="xsmall hover:text-ctp-mantle hover:bg-(--ctp-accent)">Gist</button></a> 99 99 </div> 100 100 {/each} 101 101 </div>
+6 -6
src/entrypoints/popup/routes/Themes.svelte
··· 45 45 {#each Object.entries(logos) as [logoId, logo] (logoId)} 46 46 <button 47 47 onclick={() => { 48 - globalSettings.set({ themeLogo: logoId as LogoId }); 48 + globalSettings.update({ themeLogo: logoId as LogoId }); 49 49 }} 50 50 class:highlight={globalSettings.state.themeLogo === logoId} 51 51 class="flex flex-col rounded-lg border border-(--ctp-accent) p-2"> ··· 67 67 <div class="mt-4"> 68 68 <Toggle 69 69 update={(toggled) => { 70 - globalSettings.set({ themeLogoAsFavicon: toggled }); 70 + globalSettings.update({ themeLogoAsFavicon: toggled }); 71 71 }} 72 72 checked={globalSettings.state.themeLogoAsFavicon} 73 73 id="setAsFavicon" ··· 81 81 title="Themes" 82 82 checked={globalSettings.state.themes} 83 83 update={(toggled: boolean) => { 84 - globalSettings.set({ themes: toggled }); 84 + globalSettings.update({ themes: toggled }); 85 85 }} /> 86 86 87 - <div id="flavours" class="my-6 flex rounded-xl py-2 text-ctp-text"> 87 + <div id="flavours" class="text-ctp-text my-6 flex rounded-xl py-2"> 88 88 {#each flavours as flavour (flavour)} 89 89 <button 90 90 class:active={globalSettings.state.themeFlavour === flavour} ··· 92 92 class:navbutton-right={flavour === "mocha"} 93 93 class:navbutton-center={flavour === "macchiato" || flavour === "frappe"} 94 94 onclick={() => { 95 - globalSettings.set({ themeFlavour: flavour }); 95 + globalSettings.update({ themeFlavour: flavour }); 96 96 }}>{flavour}</button> 97 97 {/each} 98 98 </div> ··· 105 105 aria-label={cleanAccent(accent)} 106 106 title={cleanAccent(accent)} 107 107 onclick={() => { 108 - globalSettings.set({ themeAccent: cleanAccent(accent) }); 108 + globalSettings.update({ themeAccent: cleanAccent(accent) }); 109 109 }}></button> 110 110 {/each} 111 111 </div>
+59 -8
src/entrypoints/start.content.ts
··· 1 1 import { browser, defineContentScript } from "#imports"; 2 - import { injectCatppuccin, injectLogo, injectStylesheet, injectUserSnippets } from "@/utils"; 2 + import { 3 + hasChanged, 4 + injectCatppuccin, 5 + injectLogo, 6 + injectStylesheet, 7 + injectUserSnippet, 8 + uninjectCatppuccin, 9 + uninjectStylesheet, 10 + uninjectUserSnippet, 11 + } from "@/utils"; 3 12 import { EXCLUDE_MATCHES, LOGO_INFO } from "@/utils/constants"; 4 - import type { LogoId } from "@/utils/storage"; 13 + import type { LogoId, Settings } from "@/utils/storage"; 5 14 import { globalSettings, schoolboxUrls } from "@/utils/storage"; 15 + import type { WatchCallback } from "wxt/utils/storage"; 6 16 import cssUrl from "./catppuccin.css?url"; 7 17 8 18 export default defineContentScript({ ··· 11 21 runAt: "document_start", 12 22 excludeMatches: EXCLUDE_MATCHES, 13 23 async main() { 14 - const settings = await globalSettings.storage.getValue(); 15 - const urls = (await schoolboxUrls.storage.getValue()).urls; 24 + const settings = await globalSettings.get(); 25 + const urls = (await schoolboxUrls.get()).urls; 26 + 27 + const updateThemes: WatchCallback<Settings> = (newValue, oldValue) => { 28 + // if global or themes was changed 29 + if (hasChanged(newValue, oldValue, ["global", "themes", "themeFlavour", "themeAccent"])) { 30 + if (newValue.global && newValue.themes) { 31 + injectThemes(); 32 + injectCatppuccin(); 33 + } else { 34 + uninjectThemes(); 35 + uninjectCatppuccin(); 36 + } 37 + } 38 + }; 39 + 40 + const updateUserSnippets: WatchCallback<Settings> = (newValue, oldValue) => { 41 + // if global or userSnippets were changed 42 + if (hasChanged(newValue, oldValue, ["global", "userSnippets"])) { 43 + for (const [id, userSnippet] of Object.entries(newValue.userSnippets)) { 44 + if (newValue.global && newValue.snippets && userSnippet.toggle) { 45 + injectUserSnippet(id); 46 + } else { 47 + uninjectUserSnippet(id); 48 + } 49 + } 50 + } 51 + }; 52 + 53 + // @ts-expect-error unlisted CSS not a PublicPath 54 + const injectThemes = () => injectStylesheet(browser.runtime.getURL(cssUrl), "themes"); 55 + const uninjectThemes = () => uninjectStylesheet("themes"); 56 + 57 + // storage listeners for hot reload 58 + globalSettings.watch((newValue, oldValue) => { 59 + updateThemes(newValue, oldValue); 60 + updateUserSnippets(newValue, oldValue); 61 + }); 16 62 17 63 if (settings.global && urls.includes(window.location.origin)) { 18 64 // inject themes 19 65 if (settings.themes) { 20 - injectStylesheet(cssUrl); 21 - injectCatppuccin(settings.themeFlavour, settings.themeAccent); 66 + injectThemes(); 67 + injectCatppuccin(); 22 68 } 23 69 24 70 // inject logo 25 71 injectLogo(LOGO_INFO[settings.themeLogo as LogoId], settings.themeLogoAsFavicon); 26 72 27 - // inject snippets 73 + // inject user snippets 28 74 if (settings.snippets) { 29 - injectUserSnippets(settings.userSnippets); 75 + const userSnippets = (await globalSettings.get()).userSnippets; 76 + for (const [id, snippet] of Object.entries(userSnippets)) { 77 + if (snippet.toggle) { 78 + injectUserSnippet(id); 79 + } 80 + } 30 81 } 31 82 32 83 // update icon
+99 -28
src/utils/index.ts
··· 1 1 import { browser } from "#imports"; 2 2 import { flavorEntries } from "@catppuccin/palette"; 3 3 import { logger } from "./logger"; 4 - import type { LogoInfo, UserSnippet } from "./storage"; 4 + import type { LogoInfo } from "./storage"; 5 + import { globalSettings } from "./storage"; 5 6 6 - export function injectStyles(styleText: string) { 7 - logger.info(`[content-utils] Injecting styles`); 7 + export const dataAttr = (id: string) => `[data-schooltape="${id}"]`; 8 + export function setDataAttr(el: HTMLElement, id: string) { 9 + el.dataset.schooltape = id; 10 + } 11 + 12 + export function injectInlineStyles(styleText: string, id: string) { 13 + logger.info(`injecting styles with id ${id}`); 8 14 const style = document.createElement("style"); 9 15 style.textContent = styleText; 10 - style.classList.add("schooltape"); 16 + setDataAttr(style, `inline-${id}`); 11 17 document.head.append(style); 18 + // logger.info(`injected styles with id ${id}`); 12 19 } 13 20 14 - export function injectCatppuccin(flavour: string, accent: string) { 15 - logger.info(`[content-utils] Injecting Catppuccin: ${flavour} ${accent}`); 21 + export function uninjectInlineStyles(id: string) { 22 + logger.info(`uninjecting styles with id ${id}`); 23 + const style = document.querySelector(dataAttr(`inline-${id}`)); 24 + if (style) document.head.removeChild(style); 25 + } 26 + 27 + export async function injectCatppuccin() { 28 + const settings = await globalSettings.get(); 29 + const flavour = settings.themeFlavour; 30 + const accent = settings.themeAccent; 31 + 32 + logger.info(`injecting catppuccin: ${flavour} ${accent}`); 16 33 let styleText = ":root {"; 17 34 const flavourArray = flavorEntries.find((entry) => entry[0] === flavour); 18 35 if (flavourArray) { ··· 24 41 }); 25 42 } 26 43 styleText += "}"; 27 - injectStyles(styleText); 44 + injectInlineStyles(styleText, "catppuccin"); 45 + } 46 + 47 + export function uninjectCatppuccin() { 48 + uninjectInlineStyles("catppuccin"); 28 49 } 29 50 30 51 export function injectLogo(logo: LogoInfo, setAsFavicon: boolean) { ··· 33 54 // eslint-disable-next-line @typescript-eslint/no-explicit-any 34 55 url = browser.runtime.getURL(url as any); 35 56 } 36 - logger.info(`[content-utils] Injecting Logo: ${logo.name}`); 57 + logger.info(`injecting logo: ${logo.name}`); 37 58 if (logo.disable) { 38 59 return; 39 60 } ··· 71 92 } 72 93 } 73 94 74 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 75 - export function injectStylesheet(url: any) { 76 - logger.info(`[content-utils] Injecting stylesheet: ${url}`); 95 + export function injectStylesheet(url: string, id: string) { 96 + // check if stylesheet has already been injected 97 + const existingLink = document.querySelector(dataAttr(`stylesheet-${id}`)); 98 + if (existingLink) return; 99 + 100 + // inject stylesheet 101 + logger.info(`injecting stylesheet with id ${id}: ${url}`); 77 102 const link = document.createElement("link"); 78 103 link.rel = "stylesheet"; 79 - link.href = browser.runtime.getURL(url); 80 - link.classList.add("schooltape"); 104 + link.href = url; 105 + setDataAttr(link, `stylesheet-${id}`); 81 106 document.head.appendChild(link); 82 107 } 83 108 84 - export async function injectUserSnippets(userSnippets: Record<string, UserSnippet>) { 85 - logger.info("[content-utils] Injecting snippets"); 86 - // user snippets 87 - Object.keys(userSnippets).forEach((snippetId) => { 88 - const userSnippet = userSnippets[snippetId]; 89 - if (userSnippet.toggle) { 90 - fetch(`https://gist.githubusercontent.com/${userSnippet.author}/${snippetId}/raw`) 91 - .then((response) => response.text()) 92 - .then((css) => { 93 - const style = document.createElement("style"); 94 - style.textContent = css; 95 - style.classList.add("schooltape"); 96 - document.head.appendChild(style); 97 - }); 109 + export function uninjectStylesheet(id: string) { 110 + logger.info(`uninjecting stylesheet with id ${id}`); 111 + 112 + const link = document.querySelector(dataAttr(`stylesheet-${id}`)); 113 + if (link) document.head.removeChild(link); 114 + } 115 + 116 + export async function injectUserSnippet(id: string) { 117 + logger.info(`injecting user snippet with id ${id}`); 118 + 119 + const userSnippets = (await globalSettings.get()).userSnippets; 120 + const snippet = userSnippets[id]; 121 + 122 + if (!snippet) { 123 + logger.error(`user snippet with id ${id} not found, aborting`); 124 + return; 125 + } 126 + 127 + if (!snippet.toggle) { 128 + logger.error(`trying to inject user snippet with id ${id} which is disabled, aborting`); 129 + return; 130 + } 131 + 132 + // check not already injected 133 + const style = document.querySelector(dataAttr(`userSnippet-${id}`)); 134 + if (style) { 135 + logger.info(`user snippet with id ${id} already injected, aborting`); 136 + return; 137 + } 138 + 139 + // inject user snippet 140 + fetch(`https://gist.githubusercontent.com/${snippet.author}/${id}/raw`) 141 + .then((response) => response.text()) 142 + .then((css) => { 143 + const style = document.createElement("style"); 144 + style.textContent = css; 145 + setDataAttr(style, `userSnippet-${id}`); 146 + document.head.appendChild(style); 147 + logger.info(`injected user snippet with id ${id}`); 148 + }); 149 + } 150 + 151 + export function uninjectUserSnippet(id: string) { 152 + logger.info(`uninjecting user snippet with id ${id}`); 153 + 154 + const style = document.querySelector(dataAttr(`userSnippet-${id}`)); 155 + if (!style) return; 156 + 157 + document.head.removeChild(style); 158 + logger.info(`uninjected user snippet with id ${id}`); 159 + } 160 + 161 + export function hasChanged<T>(newValue: T, oldValue: T, keys: (keyof T)[]) { 162 + const changed: (keyof T)[] = []; 163 + 164 + for (const key in newValue) { 165 + if (Object.prototype.hasOwnProperty.call(newValue, key) && oldValue[key] !== newValue[key]) { 166 + changed.push(key); 98 167 } 99 - }); 168 + } 169 + 170 + return keys.some((item) => changed.includes(item)); 100 171 }
+76 -27
src/utils/plugin.ts
··· 1 + import { hasChanged } from "."; 1 2 import { logger } from "./logger"; 2 3 import type { PluginId, PluginSetting, Slider } from "./storage"; 3 4 import { globalSettings, plugins, schoolboxUrls } from "./storage"; 4 5 5 6 export async function definePlugin( 6 7 pluginId: PluginId, 7 - callback: (settings?: { toggle: Record<string, boolean>; slider: Record<string, Slider> }) => Promise<void> | void, 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, 8 16 elementsToWaitFor: string[] = [], 9 17 ) { 10 - const plugin = await plugins[pluginId].toggle.storage.getValue(); 18 + const plugin = await plugins[pluginId].toggle.get(); 19 + let injected = false; 11 20 12 21 logger.info(`${plugins[pluginId].name}: ${plugin.toggle ? "enabled" : "disabled"}`); 13 22 14 - const settings = await globalSettings.storage.getValue(); 15 - const urls = (await schoolboxUrls.storage.getValue()).urls; 23 + const settings = await globalSettings.get(); 24 + const urls = (await schoolboxUrls.get()).urls; 16 25 17 26 if (plugin && typeof window !== "undefined" && urls.includes(window.location.origin)) { 18 - if (settings.global && settings.plugins && plugin.toggle) { 19 - const injectPlugin = () => { 20 - callback(getSettingsValues(plugins[pluginId]?.settings)); 21 - }; 27 + const allElementsPresent = () => elementsToWaitFor.every((selector) => document.querySelector(selector) !== null); 28 + 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 + }; 36 + 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 + }; 43 + 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 + } 54 + } 55 + }); 56 + plugins[pluginId].toggle.watch((newValue) => { 57 + if (newValue.toggle) { 58 + inject(); 59 + } else { 60 + uninject(); 61 + } 62 + }); 22 63 23 - const loadPlugin = () => { 64 + // reload plugin if settings have been updated 65 + if (plugins[pluginId].settings) { 66 + for (const setting of Object.values(plugins[pluginId].settings)) { 67 + setting.state.watch(async () => { 68 + uninject(); 69 + if ((await plugins[pluginId].toggle.get()).toggle) inject(); 70 + }); 71 + } 72 + } 73 + }; 74 + 75 + initWatchers(); 76 + 77 + if (settings.global && settings.plugins && plugin.toggle) { 78 + const initObserver = () => { 24 79 // wait for elements to be loaded 80 + 25 81 if (elementsToWaitFor.length > 0) { 82 + // create an observer to wait for all elements to be loaded 26 83 const observer = new MutationObserver((_mutations, observer) => { 27 - const allElementsPresent = elementsToWaitFor.every((selector) => document.querySelector(selector) !== null); 28 - if (allElementsPresent) { 84 + if (allElementsPresent()) { 29 85 observer.disconnect(); 30 - logger.info(`all elements present, injecting plugin: ${plugins[pluginId].name}`); 31 - injectPlugin(); 86 + inject(); 32 87 } 33 88 }); 34 - 35 89 observer.observe(document.body, { childList: true, subtree: true }); 36 90 37 91 // check if elements are already present 38 - const allElementsPresent = elementsToWaitFor.every((selector) => document.querySelector(selector) !== null); 39 - if (allElementsPresent) { 92 + if (allElementsPresent()) { 40 93 observer.disconnect(); 41 - logger.info(`all elements already present, injecting plugin: ${plugins[pluginId].name}`); 42 - injectPlugin(); 94 + inject(); 43 95 } 44 96 } else { 45 97 // no elements to wait for 46 - logger.info(`injecting plugin: ${plugins[pluginId].name}`); 47 - injectPlugin(); 98 + inject(); 48 99 } 49 100 }; 50 101 51 102 if (document.body) { 52 - loadPlugin(); 103 + initObserver(); 53 104 } else { 54 - document.addEventListener("DOMContentLoaded", loadPlugin); 105 + document.addEventListener("DOMContentLoaded", initObserver); 55 106 } 56 107 } 57 108 } 58 109 } 59 110 60 - function getSettingsValues(settings?: Record<string, PluginSetting>) { 111 + async function getSettingsValues(settings?: Record<string, PluginSetting>) { 61 112 if (!settings) return undefined; 62 113 63 114 const result: { ··· 66 117 } = { toggle: {}, slider: {} }; 67 118 for (const [key, setting] of Object.entries(settings)) { 68 119 if (setting.type === "toggle") { 69 - const value = setting.state.get(); 70 - result.toggle[key] = value.toggle; 120 + result.toggle[key] = (await setting.state.get()).toggle; 71 121 } else if (setting.type === "slider") { 72 - const value = setting.state.get(); 73 - result.slider[key] = value; 122 + result.slider[key] = await setting.state.get(); 74 123 } 75 124 } 76 125 return result;
+34 -6
src/utils/snippet.ts
··· 1 - import { injectStyles } from "."; 1 + import { hasChanged, injectInlineStyles, uninjectInlineStyles } from "."; 2 2 import { logger } from "./logger"; 3 3 import type { SnippetId } from "./storage"; 4 4 import { globalSettings, schoolboxUrls, snippets } from "./storage"; 5 5 6 6 export async function defineSnippet(snippetId: SnippetId, styleText: string) { 7 - const snippet = await snippets[snippetId].toggle.storage.getValue(); 7 + const snippet = await snippets[snippetId].toggle.get(); 8 + const inject = () => { 9 + logger.info(`injecting snippet: ${snippets[snippetId].name}`); 10 + injectInlineStyles(styleText, `snippet-${snippetId}`); 11 + }; 12 + const uninject = () => { 13 + logger.info(`uninjecting snippet: ${snippets[snippetId].name}`); 14 + uninjectInlineStyles(`snippet-${snippetId}`); 15 + }; 8 16 9 17 logger.info(`${snippets[snippetId].name}: ${snippet.toggle ? "enabled" : "disabled"}`); 10 18 11 - const settings = await globalSettings.storage.getValue(); 12 - const urls = (await schoolboxUrls.storage.getValue()).urls; 19 + const settings = await globalSettings.get(); 20 + const urls = (await schoolboxUrls.get()).urls; 13 21 14 22 if (snippet && typeof window !== "undefined" && urls.includes(window.location.origin)) { 15 23 if (settings.global && settings.snippets && snippet.toggle) { 16 24 // inject 17 - logger.info(`Injecting snippet: ${snippets[snippetId].name}`); 18 - injectStyles(styleText); 25 + inject(); 19 26 } 20 27 } 28 + 29 + // settings watcher for uninjection/injection 30 + globalSettings.watch(async (newValue, oldValue) => { 31 + if (hasChanged(newValue, oldValue, ["global", "snippets"])) { 32 + const snippet = await snippets[snippetId].toggle.get(); 33 + if (newValue.global && newValue.snippets && snippet.toggle) { 34 + inject(); 35 + } else { 36 + uninject(); 37 + } 38 + } 39 + }); 40 + snippets[snippetId].toggle.watch((newValue, oldValue) => { 41 + if (hasChanged(newValue, oldValue, ["toggle"])) { 42 + if (newValue.toggle && settings.global && settings.snippets) { 43 + inject(); 44 + } else { 45 + uninject(); 46 + } 47 + } 48 + }); 21 49 }
-7
src/utils/storage/global.ts
··· 18 18 userSnippets: {}, 19 19 }, 20 20 }), 21 - true, 22 - ); 23 - 24 - export const needsRefresh = new StorageState( 25 - storage.defineItem<boolean>("local:needsRefresh", { 26 - fallback: false, 27 - }), 28 21 ); 29 22 30 23 // whether schooltape was recently updated
-1
src/utils/storage/plugins.ts
··· 99 99 storage.defineItem<Types.Toggle>(`local:plugin-${pluginId}`, { 100 100 fallback: { toggle: pluginConfig.default }, 101 101 }), 102 - true, 103 102 ), 104 103 }; 105 104
-1
src/utils/storage/snippets.ts
··· 43 43 storage.defineItem<Types.Toggle>(`local:snippet-${snippetId}`, { 44 44 fallback: { toggle: snippetConfig.default }, 45 45 }), 46 - true, 47 46 ), 48 47 }; 49 48
+18 -20
src/utils/storage/state.svelte.ts
··· 1 1 import type { WxtStorageItem } from "#imports"; 2 - import { needsRefresh } from "./global"; 2 + import { WatchCallback } from "wxt/utils/storage"; 3 3 4 4 export class StorageState<T> { 5 5 public state; 6 + private storage; 6 7 7 - constructor( 8 - public storage: WxtStorageItem<T, {}>, 9 - refresh: boolean = false, 10 - ) { 8 + constructor(storage: WxtStorageItem<T, {}>) { 11 9 this.storage = storage; 12 10 this.state = $state(this.storage.fallback); 13 11 14 - this.storage.getValue().then(this.update); 15 - this.storage.watch((newState) => this.update(newState, refresh)); 12 + this.storage.getValue().then(this.updateState); 13 + this.storage.watch((newState) => this.updateState(newState)); 16 14 } 17 15 18 - private update = (newState: T | null, refresh?: boolean) => { 16 + private updateState = (newState: T | null) => { 19 17 this.state = newState ?? this.storage.fallback; 20 - if (refresh && this.storage.key !== "local:needsRefresh") { 21 - needsRefresh.storage.setValue(true); 22 - } 23 18 }; 24 19 25 - async set(updates: Partial<T>) { 26 - const newState = { 27 - ...(await this.storage.getValue()), 28 - ...updates, 29 - }; 20 + watch = (cb: WatchCallback<T>) => this.storage.watch(cb); 30 21 31 - await this.storage.setValue(newState); 22 + get() { 23 + return this.storage.getValue(); 24 + } 25 + 26 + set(newValue: T) { 27 + return this.storage.setValue(newValue); 32 28 } 33 29 34 - get() { 35 - this.storage.getValue().then(this.update); 36 - return $state.snapshot(this.state) as T; 30 + async update(updates: Partial<T>) { 31 + this.set({ 32 + ...(await this.get()), 33 + ...updates, 34 + }); 37 35 } 38 36 }