// Third-party libraries import browser from "webextension-polyfill" import { animate, stagger } from "motion" // Internal utilities and functions import { ChangeMenuItemPositions, MenuOptionsOpen } from "@/seqta/utils/Openers/OpenMenuOptions" import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour" import { waitForElm } from "@/seqta/utils/waitForElm" import { delay } from "@/seqta/utils/delay" import stringToHTML from "@/seqta/utils/stringToHTML" import { MessageHandler } from "@/seqta/utils/listeners/MessageListener" import { settingsState, } from "@/seqta/utils/listeners/SettingsState" import { StorageChangeHandler } from "@/seqta/utils/listeners/StorageChanges" import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat" import { eventManager } from "@/seqta/utils/listeners/EventManager" // UI and theme management import { enableNotificationCollector } from "@/seqta/utils/CreateEnable/EnableNotificationCollector" import RegisterClickListeners from "@/seqta/utils/listeners/ClickListeners" import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements" import { updateAllColors } from "@/seqta/ui/colors/Manager" import loading from "@/seqta/ui/Loading" import { SendNewsPage } from "@/seqta/utils/SendNewsPage" import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage" import { OpenWhatsNewPopup } from "@/seqta/utils/Whatsnew" // JSON content import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json" // Icons and fonts import IconFamily from "@/resources/fonts/IconFamily.woff" // Stylesheets import iframeCSS from "@/css/iframe.scss?raw" function SetDisplayNone(ElementName: string) { return `li[data-key=${ElementName}]{display:var(--menuHidden) !important; transition: 1s;}` } async function HideMenuItems(): Promise { try { let stylesheetInnerText: string = "" for (const [menuItem, { toggle }] of Object.entries( settingsState.menuitems, )) { if (!toggle) { stylesheetInnerText += SetDisplayNone(menuItem) console.info(`[BetterSEQTA+] Hiding ${menuItem} menu item`) } } const menuItemStyle: HTMLStyleElement = document.createElement("style") menuItemStyle.innerText = stylesheetInnerText document.head.appendChild(menuItemStyle) } catch (error) { console.error("[BetterSEQTA+] An error occurred:", error) } } export function hideSideBar() { const sidebar = document.getElementById("menu") // The sidebar element to be closed const main = document.getElementById("main") // The main content element that must be resized to fill the page const currentMenuWidth = window.getComputedStyle(sidebar!).width // Get the styles of the different elements const currentContentPosition = window.getComputedStyle(main!).position if (currentMenuWidth != "0") { // Actually modify it to collapse the sidebar sidebar!.style.width = "0" } else { sidebar!.style.width = "100%" } if (currentContentPosition != "relative") { main!.style.position = "relative" } else { main!.style.position = "absolute" } } export async function finishLoad() { try { document.querySelector(".legacy-root")?.classList.remove("hidden") const loadingbk = document.getElementById("loading") loadingbk?.classList.add("closeLoading") await delay(501) loadingbk?.remove() } catch (err) { console.error("Error during loading cleanup:", err) } if (settingsState.justupdated && !document.getElementById("whatsnewbk")) { OpenWhatsNewPopup() } } export function GetCSSElement(file: string) { const cssFile = browser.runtime.getURL(file) const fileref = document.createElement("link") fileref.setAttribute("rel", "stylesheet") fileref.setAttribute("type", "text/css") fileref.setAttribute("href", cssFile) return fileref } function removeThemeTagsFromNotices() { // Grabs an array of the notice iFrames const userHTMLArray = document.getElementsByClassName("userHTML") // Iterates through the array, applying the iFrame css for (const item of userHTMLArray) { // Grabs the HTML of the body tag const item1 = item as HTMLIFrameElement const body = item1.contentWindow!.document.querySelectorAll("body")[0] if (body) { // Replaces the theme tag with nothing const bodyText = body.innerHTML body.innerHTML = bodyText .replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") .replace(/ +/, " ") } } } async function updateIframesWithDarkMode(): Promise { const cssLink = document.createElement("style") cssLink.classList.add("iframecss") const cssContent = document.createTextNode(iframeCSS) cssLink.appendChild(cssContent) eventManager.register( "iframeAdded", { elementType: "iframe", customCheck: (element: Element) => !element.classList.contains("iframecss"), }, (element) => { const iframe = element as HTMLIFrameElement try { applyDarkModeToIframe(iframe, cssLink) if (element.classList.contains("cke_wysiwyg_frame")) { (async () => { await delay(100) iframe.contentDocument?.body.setAttribute("spellcheck", "true") })() } } catch (error) { console.error("Error applying dark mode:", error) } }, ) } function applyDarkModeToIframe( iframe: HTMLIFrameElement, cssLink: HTMLStyleElement, ): void { const iframeDocument = iframe.contentDocument if (!iframeDocument) return iframe.onload = () => { applyDarkModeToIframe(iframe, cssLink) } if (settingsState.DarkMode) { iframeDocument.documentElement.classList.add("dark") } const head = iframeDocument.head if (head && !head.innerHTML.includes("iframecss")) { head.innerHTML += cssLink.outerHTML } } function SortMessagePageItems(messagesParentElement: any) { let filterbutton = document.createElement("div") filterbutton.classList.add("messages-filterbutton") filterbutton.innerText = "Filter" let header = document.getElementsByClassName( "MessageList__MessageList___3DxoC", )[0].firstChild as HTMLElement header.append(filterbutton) messagesParentElement } async function LoadPageElements(): Promise { await AddBetterSEQTAElements() const sublink: string | undefined = window.location.href.split("/")[4] eventManager.register( "messagesAdded", { elementType: "div", className: "messages", }, handleMessages, ) eventManager.register( "noticesAdded", { elementType: "div", className: "notices", }, CheckNoticeTextColour, ) eventManager.register( "dashboardAdded", { elementType: "div", className: "dashboard", }, handleDashboard, ) eventManager.register( "documentsAdded", { elementType: "div", className: "documents", }, handleDocuments, ) eventManager.register( "reportsAdded", { elementType: "div", className: "reports", }, handleReports, ) eventManager.register( "timetableAdded", { elementType: "div", className: "timetablepage", }, handleTimetable, ) eventManager.register( "noticesAdded", { elementType: "div", className: "notice", }, handleNotices, ) if (settingsState.assessmentsAverage) { eventManager.register( "assessmentsAdded", { elementType: "div", className: "assessmentsWrapper", }, handleAssessments, ) } RegisterClickListeners() await handleSublink(sublink) } function handleTimetableZoom(): void { console.log("Initializing timetable zoom controls") // Lazy initialize state variables only when function is first called let timetableZoomLevel = 1 let baseContainerHeight: number | null = null const originalEntryPositions = new Map< Element, { topRatio: number; heightRatio: number } >() // Create zoom controls const zoomControls = document.createElement("div") zoomControls.className = "timetable-zoom-controls" const zoomIn = document.createElement("button") zoomIn.className = "uiButton timetable-zoom iconFamily" zoomIn.innerHTML = "" // Using unicode for zoom in icon const zoomOut = document.createElement("button") zoomOut.className = "uiButton timetable-zoom iconFamily" zoomOut.innerHTML = "" // Using unicode for zoom out icon zoomControls.appendChild(zoomOut) zoomControls.appendChild(zoomIn) const toolbar = document.getElementById("toolbar") toolbar?.appendChild(zoomControls) const initializePositions = () => { // Get the base container height from the first TD const firstDayColumn = document.querySelector( ".dailycal .content .days td", ) as HTMLElement if (!firstDayColumn) return false baseContainerHeight = parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight // Store original ratios const entries = document.querySelectorAll(".entriesWrapper .entry") entries.forEach((entry: Element) => { const entryEl = entry as HTMLElement // Calculate ratios relative to detected base height if (baseContainerHeight === null) return const topRatio = parseInt(entryEl.style.top) / baseContainerHeight const heightRatio = parseInt(entryEl.style.height) / baseContainerHeight originalEntryPositions.set(entry, { topRatio, heightRatio }) }) return true } const updateZoom = () => { // Initialize positions if not already done if (baseContainerHeight === null && !initializePositions()) { console.error("Failed to initialize positions") return } console.debug(`Updating zoom level to: ${timetableZoomLevel}`) // Calculate new container height if (baseContainerHeight === null) return const newContainerHeight = baseContainerHeight * timetableZoomLevel // Update all day columns (TDs) const dayColumns = document.querySelectorAll(".dailycal .content .days td") dayColumns.forEach((td: Element) => { (td as HTMLElement).style.height = `${newContainerHeight}px` }) // Update all entries using stored ratios const entries = document.querySelectorAll(".entriesWrapper .entry") entries.forEach((entry: Element) => { const entryEl = entry as HTMLElement const originalRatios = originalEntryPositions.get(entry) if (originalRatios) { // Calculate new positions from original ratios const newTop = originalRatios.topRatio * newContainerHeight const newHeight = originalRatios.heightRatio * newContainerHeight // Apply new values entryEl.style.top = `${Math.round(newTop)}px` entryEl.style.height = `${Math.round(newHeight)}px` } }) // Update time column to match const timeColumn = document.querySelector(".times") if (timeColumn) { const times = timeColumn.querySelectorAll(".time") const timeHeight = newContainerHeight / times.length times.forEach((time: Element) => { (time as HTMLElement).style.height = `${timeHeight}px` }) } entries[Math.round((entries.length - 1) / 2)].scrollIntoView({ behavior: "instant", block: "center", }) } zoomIn.addEventListener("click", () => { if (timetableZoomLevel < 2) { timetableZoomLevel += 0.2 updateZoom() } }) zoomOut.addEventListener("click", () => { if (timetableZoomLevel > 0.6) { timetableZoomLevel -= 0.2 updateZoom() } }) } function handleTimetableAssessmentHide(): void { const hideControls = document.createElement("div") // Creates the div element which houses the eye icon hideControls.className = "timetable-hide-controls" const hideOn = document.createElement("button") // Creates the actual button which is clicked hideOn.className = "uiButton timetable-hide iconFamily" hideOn.innerHTML = "👁" // Using unicode for hide icon hideControls.appendChild(hideOn) const toolbar = document.getElementById("toolbar") // Appends the new button to the toolbar toolbar?.appendChild(hideControls) function hideElements(): void { const entries = document.querySelectorAll(".entry") // Gets all the timetables entries on the page, and loops through entries.forEach((entry: Element) => { const entryEl = entry as HTMLElement if (!entryEl.classList.contains("assessment") && !(entryEl.style.opacity === "0.3")) { // If the entry is not an assessment, and hasn't already been hidden, hide it. entryEl.style.opacity = "0.3" } else { // Otherwise, it should be shown. entryEl.style.opacity = "1" } }) } hideOn.addEventListener("click", () => { // Listen for when the button is pressed hideElements() }) } async function handleNotices(node: Element): Promise { if (!(node instanceof HTMLElement)) return if (!settingsState.animations) return node.style.opacity = "0" // get index of node in relation to parent const index = Array.from(node.parentElement!.children).indexOf(node) animate( node, { opacity: [0, 1], y: [50, 0], scale: [0.99, 1] }, { delay: 0.1 * index, type: "spring", stiffness: 250, damping: 20, }, ) } async function handleSublink(sublink: string | undefined): Promise { switch (sublink) { case "news": await handleNewsPage() break case undefined: window.location.replace( `${location.origin}/#?page=/${settingsState.defaultPage}`, ) if (settingsState.defaultPage === "home") loadHomePage() if (settingsState.defaultPage === "timetable") handleTimetable() if (settingsState.defaultPage === "documents") handleDocuments(document.querySelector(".documents")!) if (settingsState.defaultPage === "reports") handleReports(document.querySelector(".reports")!) if (settingsState.defaultPage === "messages") handleMessages(document.querySelector(".messages")!) finishLoad() break case "home": window.location.replace(`${location.origin}/#?page=/home`) console.info("[BetterSEQTA+] Started Init") if (settingsState.onoff) loadHomePage() finishLoad() break default: await handleDefault() break } } async function handleTimetable(): Promise { await waitForElm(".time", true, 10) // Store original heights when timetable loads const lessons = document.querySelectorAll(".dailycal .lesson") lessons.forEach((lesson: Element) => { const lessonEl = lesson as HTMLElement lessonEl.setAttribute( "data-original-height", lessonEl.offsetHeight.toString(), ) }) // Existing time format code if (settingsState.timeFormat == "12") { const times = document.querySelectorAll(".timetablepage .times .time") for (const time of times) { if (!time.textContent) continue time.textContent = convertTo12HourFormat(time.textContent, true) } } handleTimetableZoom() handleTimetableAssessmentHide() } async function handleNewsPage(): Promise { console.info("[BetterSEQTA+] Started Init") if (settingsState.onoff) { SendNewsPage() if (settingsState.notificationcollector) { enableNotificationCollector() } finishLoad() } } async function handleDefault(): Promise { finishLoad() if (settingsState.notificationcollector) { enableNotificationCollector() } } async function handleMessages(node: Element): Promise { if (!(node instanceof HTMLElement)) return const element = document.getElementById("title")!.firstChild as HTMLElement element.innerText = "Direct Messages" document.title = "Direct Messages ― SEQTA Learn" SortMessagePageItems(node) if (!settingsState.animations) return // Hides messages on page load const style = document.createElement("style") style.classList.add("messageHider") style.innerHTML = "[data-message]{opacity: 0 !important;}" document.head.append(style) await waitForElm("[data-message]", true, 10) const messages = Array.from( document.querySelectorAll("[data-message]"), ).slice(0, 35) animate( messages, { opacity: [0, 1], y: [10, 0] }, { delay: stagger(0.03), duration: 0.5, ease: [0.22, 0.03, 0.26, 1], }, ) document.head.querySelector("style.messageHider")?.remove() } async function handleDashboard(node: Element): Promise { if (!(node instanceof HTMLElement)) return if (!settingsState.animations) return const style = document.createElement("style") style.classList.add("dashboardHider") style.innerHTML = ".dashboard{opacity: 0 !important;}" document.head.append(style) await waitForElm(".dashlet", true, 10) animate( ".dashboard > *", { opacity: [0, 1], y: [10, 0] }, { delay: stagger(0.1), duration: 0.5, ease: [0.22, 0.03, 0.26, 1], }, ) document.head.querySelector("style.dashboardHider")?.remove() } async function handleDocuments(node: Element): Promise { if (!(node instanceof HTMLElement)) return if (!settingsState.animations) return await waitForElm(".document", true, 10) animate( ".documents tbody tr.document", { opacity: [0, 1], y: [10, 0] }, { delay: stagger(0.05), duration: 0.5, ease: [0.22, 0.03, 0.26, 1], }, ) } async function handleReports(node: Element): Promise { if (!(node instanceof HTMLElement)) return if (!settingsState.animations) return await waitForElm(".report", true, 10) animate( ".reports .item", { opacity: [0, 1], y: [10, 0] }, { delay: stagger(0.05, { startDelay: 0.2 }), duration: 0.5, ease: [0.22, 0.03, 0.26, 1], }, ) } function CheckNoticeTextColour(notice: any) { eventManager.register( "noticeAdded", { elementType: "div", className: "notice", parentElement: notice, }, (node) => { var hex = (node as HTMLElement).style.cssText.split(" ")[1] if (hex) { const hex1 = hex.slice(0, -1) var threshold = GetThresholdOfColor(hex1) if (settingsState.DarkMode && threshold < 100) { (node as HTMLElement).style.cssText = "--color: undefined;" } } }, ) } export function tryLoad() { waitForElm(".login").then(() => { finishLoad() }) waitForElm(".day-container").then(() => { finishLoad() }) waitForElm("[data-key=welcome]").then((elm: any) => { elm.classList.remove("active") }) waitForElm(".code", true, 50).then((elm: any) => { if (!elm.innerText.includes("BetterSEQTA")) LoadPageElements() }) updateIframesWithDarkMode() // Waits for page to call on load, run scripts document.addEventListener( "load", function () { removeThemeTagsFromNotices() }, true, ) } function ReplaceMenuSVG(element: HTMLElement, svg: string) { let item = element.firstChild as HTMLElement item!.firstChild!.remove() item.innerHTML = `${item.innerHTML}` let newsvg = stringToHTML(svg).firstChild item.insertBefore(newsvg as Node, item.firstChild) } export async function ObserveMenuItemPosition() { await waitForElm("#menu > ul > li") await delay(100) eventManager.register( "menuList", { parentElement: document.querySelector("#menu")!.firstChild as Element, }, (element: Element) => { const node = element as HTMLElement if (!node?.dataset?.checked && !MenuOptionsOpen) { const key = MenuitemSVGKey[node?.dataset?.key! as keyof typeof MenuitemSVGKey] if (key) { ReplaceMenuSVG( node, MenuitemSVGKey[node.dataset.key as keyof typeof MenuitemSVGKey], ) } else if (node?.firstChild?.nodeName === "LABEL") { const label = node.firstChild as HTMLElement let textNode = label.lastChild as HTMLElement if ( textNode.nodeType === 3 && textNode.parentNode && textNode.parentNode.nodeName !== "SPAN" ) { const span = document.createElement("span") span.textContent = textNode.nodeValue label.replaceChild(span, textNode) } } ChangeMenuItemPositions(settingsState.menuorder) } }, ) } export function showConflictPopup() { if (document.getElementById("conflict-popup")) return document.body.classList.remove("hidden") const background = document.createElement("div") background.id = "conflict-popup" background.classList.add("whatsnewBackground") background.style.zIndex = "10000000" const container = document.createElement("div") container.classList.add("whatsnewContainer") container.style.height = "auto" const headerHTML = /* html */ `

Extension Conflict Detected

Legacy BetterSEQTA Installed

` const header = stringToHTML(headerHTML).firstChild const textHTML = /* html */ `

It appears that you have the legacy BetterSEQTA extension installed alongside BetterSEQTA+. This conflict may cause unexpected behavior. (and breaks the extension)

Please remove the older BetterSEQTA extension to ensure that BetterSEQTA+ works correctly.

` const text = stringToHTML(textHTML).firstChild const exitButton = document.createElement("div") exitButton.id = "whatsnewclosebutton" if (header) container.append(header) if (text) container.append(text) container.append(exitButton) background.append(container) document.getElementById("container")?.append(background) if (settingsState.animations) { animate([background as HTMLElement], { opacity: [0, 1] }) } background.addEventListener("click", (event) => { if (event.target === background) { background.remove() } }) exitButton.addEventListener("click", () => { background.remove() }) } export function init() { if (typeof settingsState.onoff === "undefined") { browser.runtime.sendMessage({ type: "setDefaultStorage" }) } const handleDisabled = () => { waitForElm(".code", true, 50).then(AppendElementsToDisabledPage) } if (settingsState.onoff) { console.info("[BetterSEQTA+] Enabled") if (settingsState.DarkMode) document.documentElement.classList.add("dark") document.querySelector(".legacy-root")?.classList.add("hidden") new StorageChangeHandler() new MessageHandler() updateAllColors() loading() InjectCustomIcons() HideMenuItems() tryLoad() setTimeout(() => { const legacyElement = document.querySelector( ".outside-container .bottom-container", ) if (legacyElement) { console.log("Legacy extension detected") showConflictPopup() } }, 1000) } else { handleDisabled() window.addEventListener("load", handleDisabled) } } function InjectCustomIcons() { console.info("[BetterSEQTA+] Injecting Icons") const style = document.createElement("style") style.setAttribute("type", "text/css") style.innerHTML = ` @font-face { font-family: 'IconFamily'; src: url('${browser.runtime.getURL(IconFamily)}') format('woff'); font-weight: normal; font-style: normal; }` document.head.appendChild(style) } export function AppendElementsToDisabledPage() { console.info("[BetterSEQTA+] Appending elements to disabled page") AddBetterSEQTAElements() let settingsStyle = document.createElement("style") settingsStyle.innerHTML = /* css */ ` .addedButton { position: absolute !important; right: 50px; width: 35px; height: 35px; padding: 6px !important; overflow: unset !important; border-radius: 50%; margin: 7px !important; cursor: pointer; color: white !important; } .addedButton svg { margin: 6px; } .outside-container { top: 48px !important; } #ExtensionPopup { border-radius: 1rem; box-shadow: 0px 0px 20px -2px rgba(0, 0, 0, 0.6); transform-origin: 70% 0; } ` document.head.append(settingsStyle) } /*async function CheckForMenuList() { try { await waitForElm("#menu > ul") ObserveMenuItemPosition() } catch (error) { return } }*/ async function handleAssessments(node: Element): Promise { if (!(node instanceof HTMLElement)) return // Wait for the assessments wrapper to be mounted const assessmentsWrapper = await waitForElm( "#main > .assessmentsWrapper .assessments .AssessmentItem__AssessmentItem___2EZ95", true, 50, ) if (!assessmentsWrapper) return // Grade conversion map for letter grades const letterGradeMap: Record = { "A+": 100, A: 95, "A-": 90, "B+": 85, B: 80, "B-": 75, "C+": 70, C: 65, "C-": 60, "D+": 55, D: 50, "D-": 45, "E+": 40, E: 35, "E-": 30, F: 0, } // Function to parse grade text into a number function parseGrade(gradeText: string): number { // Remove any whitespace const trimmedGrade = gradeText.trim().toUpperCase() // Check if it is a non-percent grade if (trimmedGrade.includes("/")) { const grade = trimmedGrade.split("/") var a = grade[1] as unknown as number var b = grade[0] as unknown as number return (b / a) * 100 } // Check if it's a percentage if (trimmedGrade.includes("%")) { return parseFloat(trimmedGrade.replace("%", "")) || 0 } // Check if it's a letter grade if (Object.prototype.hasOwnProperty.call(letterGradeMap, trimmedGrade)) { return letterGradeMap[trimmedGrade] } return 0 } // Function to calculate average of grades function calculateAverageGrade(): number { const gradeElements = document.querySelectorAll( ".Thermoscore__text___1NdvB", ) let total = 0 let count = 0 gradeElements.forEach((element) => { const gradeText = element.textContent || "" const grade = parseGrade(gradeText) if (grade > 0) { total += grade count++ } }) return count > 0 ? total / count : 0 } // Function to add the average assessment item function addAverageAssessment() { const numaverage = calculateAverageGrade() if (numaverage === 0) return // Remove existing average section if it exists const existingAverage = document.querySelector( ".AssessmentItem__AssessmentItem___2EZ95:first-child", ) if ( existingAverage?.querySelector(".AssessmentItem__title___2bELn") ?.textContent === "Subject Average" ) { existingAverage.remove() } const preaverage = numaverage.toFixed(0) as unknown as number const prepaverage = Math.ceil(preaverage / 5) * 5 const NumberGradeMap: Record = { 100: "A+", 95: "A", 90: "A-", 85: "B+", 80: "B", 75: "B-", 70: "C+", 65: "C", 60: "C-", 55: "D+", 50: "D", 45: "D-", 40: "E+", 35: "E", 30: "E-", 0: "F", } var letteraverage = "N/A" const check = Object.prototype.hasOwnProperty.call( NumberGradeMap, prepaverage, ) if (check) { console.debug("[BetterSEQTA+ Debugger] Match found") letteraverage = NumberGradeMap[prepaverage] } else { console.debug("[BetterSEQTA+ Debugger] No match found") letteraverage = "N/A" } var average = "N/A" if (settingsState.lettergrade) { average = letteraverage } else { average = `${numaverage.toFixed(2)}%` } const averageElement = stringToHTML(/* html */ `
Subject Average
${average}
`) // Insert at the beginning of the assessments list const assessmentsList = document.querySelector( ".assessments .AssessmentList__items___3LcmQ", ) if (assessmentsList && averageElement.firstChild) { assessmentsList.insertBefore( averageElement.firstChild, assessmentsList.firstChild, ) } } // Add the average assessment item addAverageAssessment() }