diff --git a/css/all.css b/css/all.css index 803cbda7..75596193 100644 --- a/css/all.css +++ b/css/all.css @@ -929,7 +929,7 @@ a._3_bfp { #center-inner.splus-api-key-page #center-top .page-title, #center-inner.splus-api-key-page #content-wrapper form p.description, -#center-inner.splus-api-key-page .submit-span-wrapper { +#center-inner.splus-api-key-page .submit-span-wrapper:not(.splus-modal-button) { display: none; } diff --git a/css/grades.css b/css/grades.css index c4cb6387..610b32ca 100644 --- a/css/grades.css +++ b/css/grades.css @@ -108,11 +108,13 @@ col.comments-column { .category-row img.modified-score-percent-warning { width: 12px; + height: 12px; } .period-row img.modified-score-percent-warning { margin-bottom: 1px; width: 15px; + height: 15px; } .relative-span-contain { diff --git a/js/all.js b/js/all.js index 7159364c..8ea3db7d 100644 --- a/js/all.js +++ b/js/all.js @@ -54,10 +54,10 @@ // Check Schoology domain setTimeout(function () { - const BLACKLISTED_DOMAINS = ["asset-cdn.schoology.com", "developer.schoology.com", "support.schoology.com", "info.schoology.com", "files-cdn.schoology.com", "status.schoology.com", "ui.schoology.com", "www.schoology.com", "api.schoology.com", "developers.schoology.com", "schoology.com", "support.schoology.com", "error-page.schoology.com", "app-msft-teams.schoology.com"]; + const DENYLISTED_DOMAINS = ["asset-cdn.schoology.com", "developer.schoology.com", "support.schoology.com", "info.schoology.com", "files-cdn.schoology.com", "status.schoology.com", "ui.schoology.com", "www.schoology.com", "api.schoology.com", "developers.schoology.com", "schoology.com", "error-page.schoology.com", "app-msft-teams.schoology.com", "lti-submission-google.app.schoology.com", "lti-submission-microsoft.app.schoology.com", "googledrive.app.schoology.com", "onedrive.app.schoology.com"]; let dd = Setting.getValue("defaultDomain"); - if (dd !== window.location.hostname && !BLACKLISTED_DOMAINS.includes(window.location.hostname) && !window.location.hostname.match(/.*[-\.]app\.schoology\.com/)) { + if (dd !== window.location.hostname && !DENYLISTED_DOMAINS.includes(window.location.hostname) && !window.location.hostname.match(/.*[-\.]app\.schoology\.com/)) { Setting.setValue("defaultDomain", window.location.hostname, function () { let bgColor = document.querySelector("#header header").style.backgroundColor; @@ -390,7 +390,14 @@ let modals = [ } e.target.classList.add("active"); - trackEvent("selected tile", obj.text, "Choose Theme Popup"); + trackEvent("button_click", { + id: "preview-theme", + context: "Choose Theme Popup", + value: obj.text, + legacyTarget: "selected tile", + legacyAction: obj.text, + legacyLabel: "Choose Theme Popup" + }); tempTheme = obj.theme; Theme.apply(Theme.byName(obj.theme)); @@ -407,7 +414,17 @@ let modals = [ (() => { let btn = createButton("theme-popup-select-button", `Select Keep Current Theme: ${Theme.active.name}`, e => { localStorage.setItem("splus-temp-theme-chosen", true); - trackEvent("confirmed selection", document.querySelector(".select-theme-tile.active .splus-button-tile-content").textContent, "Choose Theme Popup"); + let themeName = document.querySelector(".select-theme-tile.active .splus-button-tile-content").textContent; + + trackEvent("button_click", { + id: "apply-theme", + context: "Choose Theme Popup", + value: themeName, + legacyTarget: "confirmed selection", + legacyAction: themeName, + legacyLabel: "Choose Theme Popup" + }); + modalClose(document.getElementById("choose-theme-modal")); Setting.setValue("theme", tempTheme); if (document.getElementById("choose-theme-modal").querySelector(".splus-button-tile-container .splus-button-tile:last-child").classList.contains("active")) { @@ -461,7 +478,7 @@ let modals = [ instance.hide({ transitionOut: 'fadeOutRight', onClosing: function (instance, toast, closedBy) { - trackEvent('viewChangelogButton', "click", "Toast Button"); + trackEvent("button_click", {id: "viewChangelogButton", context: "Toast", legacyTarget: "viewChangelogButton", legacyAction: "click", legacyLabel: "Toast Button"}) openModal("changelog-modal"); } }, toast, 'viewChangelogButton'); @@ -525,7 +542,15 @@ document.body.onkeydown = (data) => { video.style.visibility = "visible"; video.currentTime = 0; video.play(); - trackEvent("Easter Egg", "play", "Easter Egg"); + + trackEvent("perform_action", { + id: "activate", + context: "Easter Egg", + value: "confetti", + legacyTarget: "Easter Egg", + legacyAction: "play", + legacyLabel: "Easter Egg" + }); } else if (data.altKey && data.code === "KeyB") { openModal("beta-modal"); } @@ -552,7 +577,14 @@ document.querySelector("#header > header > nav > ul:nth-child(2)").prepend(creat } Theme.apply(Theme.active); document.documentElement.setAttribute("modern", newVal); - trackEvent("modern-theme-toggle", newVal, "Navbar Button"); + trackEvent("button_click", { + id: "modern-theme-toggle", + context: "Navbar", + value: newVal, + legacyTarget: "modern-theme-toggle", + legacyAction: newVal, + legacyLabel: "Navbar Button" + }); }, dataset: { popup: Setting.getNestedValue("popup", "modernThemeToggle", true) && (localStorage.getItem("popup.modernThemeToggle") !== "false") } }, @@ -563,7 +595,13 @@ document.querySelector("#header > header > nav > ul:nth-child(2)").prepend(creat textContent: "OK", onclick: e => { e.stopPropagation(); - trackEvent("modern-theme-toggle", "ok", "Explanation Popup"); + trackEvent("button_click", { + id: "modern-theme-toggle-explanation-ok", + context: "Explanation Popup", + legacyTarget: "modern-theme-toggle", + legacyAction: "ok", + legacyLabel: "Explanation Popup" + }); Setting.setNestedValue("popup", "modernThemeToggle", false); localStorage.setItem("popup.modernThemeToggle", "false"); document.getElementById("darktheme-toggle-navbar-button").dataset.popup = false; @@ -592,7 +630,13 @@ document.querySelector("#header > header > nav > ul:nth-child(2)").prepend(creat title: "Schoology Plus Settings\n\nChange settings relating to Schoology Plus.", onclick: () => { openModal("settings-modal"); - trackEvent("splus-settings", "open", "Navbar Button"); + trackEvent("button_click", { + id: "splus-settings", + context: "Navbar", + legacyTarget: "splus-settings", + legacyAction: "open", + legacyLabel: "Navbar Button" + }); } }, [ @@ -631,7 +675,14 @@ function openModal(id, options) { modalClose(m.element); } - trackEvent(id, "open", "Modal"); + trackEvent("perform_action", { + id: "open", + context: "Modal", + value: id, + legacyTarget: id, + legacyAction: "open", + legacyLabel: "Modal" + }); let mm = modals.find(m => m.id == id); if (mm.onopen) mm.onopen(mm, options); @@ -1109,7 +1160,7 @@ async function createQuickAccess() { for (let page of PAGES) { let a = linkWrap.appendChild(createElement("a", ["quick-link", "splus-track-clicks"], page)); - a.dataset.splusTrackingLabel = "Quick Access"; + a.dataset.splusTrackingContext = "Quick Access"; } wrapper.appendChild( @@ -1133,17 +1184,17 @@ async function createQuickAccess() { for (let section of sectionsList) { wrapper.appendChild(createElement("div", ["quick-access-course"], {}, [ (iconImage = createElement("div", ["splus-course-icon"], { dataset: { courseTitle: `${section.course_title}: ${section.section_title}` } })), - createElement("a", ["splus-track-clicks", "quick-course-link"], { textContent: `${section.course_title}: ${section.section_title}`, href: `/course/${section.id}`, dataset: { splusTrackingTarget: "quick-access-course-link", splusTrackingLabel: "Quick Access" } }), + createElement("a", ["splus-track-clicks", "quick-course-link"], { textContent: `${section.course_title}: ${section.section_title}`, href: `/course/${section.id}`, dataset: { splusTrackingId: "quick-access-course-link", splusTrackingContext: "Quick Access" } }), (courseIconsContainer = createElement("div", ["icons-container"], {}, [ - createElement("a", ["icon", "icon-grades", "splus-track-clicks"], { href: `/course/${section.id}/student_grades`, title: "Grades", dataset: { splusTrackingTarget: "quick-access-grades-link", splusTrackingLabel: "Quick Access" } }), - createElement("a", ["icon", "icon-mastery", "splus-track-clicks"], { href: `/course/${section.id}/student_mastery`, title: "Mastery", dataset: { splusTrackingTarget: "quick-access-mastery-link", splusTrackingLabel: "Quick Access" } }), - (courseOptionsButton = createElement("a", ["icon", "icon-settings", "splus-track-clicks"], { href: "#", dataset: { splusTrackingTarget: "quick-access-settings-link", splusTrackingLabel: "Quick Access" } })) + createElement("a", ["icon", "icon-grades", "splus-track-clicks"], { href: `/course/${section.id}/student_grades`, title: "Grades", dataset: { splusTrackingId: "quick-access-grades-link", splusTrackingContext: "Quick Access" } }), + createElement("a", ["icon", "icon-mastery", "splus-track-clicks"], { href: `/course/${section.id}/student_mastery`, title: "Mastery", dataset: { splusTrackingId: "quick-access-mastery-link", splusTrackingContext: "Quick Access" } }), + (courseOptionsButton = createElement("a", ["icon", "icon-settings", "splus-track-clicks"], { href: "#", dataset: { splusTrackingId: "quick-access-settings-link", splusTrackingContext: "Quick Access" } })) ])) ])); let quickLink = Setting.getNestedValue("courseQuickLinks", section.id); if (quickLink && quickLink !== "") { - courseIconsContainer.prepend(createElement("a", ["icon", "icon-quicklink", "splus-track-clicks"], { href: quickLink, title: `Quick Link \n(${quickLink})`, dataset: { splusTrackingTarget: "quick-access-quicklink-link", splusTrackingLabel: "Quick Access" } })) + courseIconsContainer.prepend(createElement("a", ["icon", "icon-quicklink", "splus-track-clicks"], { href: quickLink, title: `Quick Link \n(${quickLink})`, dataset: { splusTrackingId: "quick-access-quicklink-link", splusTrackingContext: "Quick Access" } })) } iconImage.style.backgroundImage = `url(${chrome.runtime.getURL("imgs/fallback-course-icon.svg")})`; @@ -1231,13 +1282,27 @@ function indicateSubmittedAssignments() { if (eventElement.classList.contains(assignCompleteClass) && isAssignmentMarkedComplete(assignmentId)) { eventElement.classList.remove(assignCompleteClass); setAssignmentCompleteOverride(assignmentId, false); - trackEvent("splus-completed-check-indicator", "uncheck", "Checkmarks"); + trackEvent("button_click", { + id: "splus-completed-check-indicator", + context: "Checklist", + value: "uncheck", + legacyTarget: "splus-completed-check-indicator", + legacyAction: "uncheck", + legacyLabel: "Checkmarks" + }); // TODO handle async nicely processAssignmentUpcomingAsync(eventElement); // if we're incomplete and click, force the completed state } else if (eventElement.classList.contains(assignIncompleteClass)) { eventElement.classList.remove(assignIncompleteClass); - trackEvent("splus-completed-check-indicator", "check", "Checkmarks"); + trackEvent("button_click", { + id: "splus-completed-check-indicator", + context: "Checklist", + value: "check", + legacyTarget: "splus-completed-check-indicator", + legacyAction: "check", + legacyLabel: "Checkmarks" + }); setAssignmentCompleteOverride(assignmentId, true); // TODO handle async nicely processAssignmentUpcomingAsync(eventElement); diff --git a/js/analytics.js b/js/analytics.js index 647af035..d0e2214b 100644 --- a/js/analytics.js +++ b/js/analytics.js @@ -1,4 +1,4 @@ -(async function() { +(async function () { // Wait for loader.js to finish running while (!window.splusLoaded) { await new Promise(resolve => setTimeout(resolve, 10)); @@ -9,13 +9,20 @@ /** * Tracks an event using Google Analytics if the user did not opt out * NOTE: The Firefox version of the extension has no support for Google Analytics - * @param {string} target (Event Category) The target of the event - * @param {string} action (Event Action) The action of the event - * @param {string} [label] (Event Label) Used to group related events - * @param {number} [value] Numeric value associated with the event + * @param {string} eventName The event name + * @param {Object} param1 Event properties */ -var trackEvent = function (target, action, label = undefined, value = undefined) { - console.debug("[S+] Tracking disabled by user", { target, action, label, value }); +var trackEvent = function (eventName, { + legacyTarget, + legacyAction, + legacyLabel = undefined, + legacyValue = undefined, + id, + context, + value, + ...extraProps +} = {}) { + console.debug("[S+] Tracking disabled by user", arguments); }; (function () { @@ -32,13 +39,34 @@ var trackEvent = function (target, action, label = undefined, value = undefined) } } + function getRandomToken() { + // E.g. 8 * 32 = 256 bits token + var randomPool = new Uint8Array(32); + crypto.getRandomValues(randomPool); + var hex = ''; + for (var i = 0; i < randomPool.length; ++i) { + hex += randomPool[i].toString(16); + } + // E.g. db18458e2782b2b77e36769c569e263a53885a9944dd0a861e5064eac16f1a + return hex; + } + chrome.storage.sync.get({ analytics: getBrowser() === "Firefox" ? "disabled" : "enabled", theme: "", beta: "", newVersion: "" }, s => { if (s.analytics === "enabled") { - enableAnalytics(s.theme, s.beta, s.newVersion); + chrome.storage.local.get({randomUserId: null}, l => { + if (!l.randomUserId) { + let randomToken = getRandomToken(); + chrome.storage.local.set({randomUserId: randomToken}, () => { + enableAnalytics(s.theme, s.beta, s.newVersion, randomToken); + }); + } else { + enableAnalytics(s.theme, s.beta, s.newVersion, l.randomUserId); + } + }); } }); - function enableAnalytics(selectedTheme, beta, newVersion) { + function enableAnalytics(selectedTheme, beta, newVersion, randomUserId) { // isogram let r = 'ga'; window['GoogleAnalyticsObject'] = r; @@ -58,15 +86,63 @@ var trackEvent = function (target, action, label = undefined, value = undefined) ga('set', 'dimension6', newVersion); ga('send', 'pageview', location.pathname.replace(/\/\d{3,}\b/g, "/*") + location.search); - trackEvent = function (target, action, label = undefined, value = undefined) { + // Google Analytics v4 + window.dataLayer = window.dataLayer || []; + function gtag() { dataLayer.push(arguments); } + gtag('js', new Date()); + + gtag('config', 'G-YM6B00RDYC', { + page_location: location.href.replace(/\/\d{3,}\b/g, "/*"), + page_path: location.pathname.replace(/\/\d{3,}\b/g, "/*"), + page_title: null, + user_id: randomUserId + }); + + let trackEventOld = function (target, action, label = undefined, value = undefined) { ga('send', 'event', target, action, label, value); - console.debug(`[S+] Tracked event:`, { target, action, label, value }); + console.debug(`[S+] Tracked event [OLD]:`, { target, action, label, value }); + }; + + trackEvent = function (eventName, { + legacyTarget, + legacyAction, + legacyLabel = undefined, + legacyValue = undefined, + id, + context, + value, + ...extraProps + } = {}) { + trackEventOld(legacyTarget, legacyAction, legacyLabel, legacyValue); + let eventData = { + extensionVersion: chrome.runtime.getManifest().version, + domain: location.host, + theme: selectedTheme, + modernTheme: document.documentElement.getAttribute("modern"), + activeBeta: beta, + lastEnabledVersion: newVersion, + id, + context, + value, + ...extraProps + }; + console.debug(`[S+] Tracked event:`, eventName, eventData); + gtag("event", eventName, eventData); }; function trackClick(event) { - if(!event.isTrusted) return; + if (!event.isTrusted) return; let target = event.currentTarget || event.target; - trackEvent(target.dataset.splusTrackingTarget || target.id || "Unlabeled Button", "click", target.dataset.splusTrackingLabel || "Tracking Link", target.dataset.splusTrackingValue || event.button); + + trackEvent("tracking_link_click", { + legacyTarget: target.dataset.splusTrackingId || target.id || "Unlabeled Button", + legacyAction: "click", + legacyLabel: target.dataset.splusTrackingContext || "Tracking Link", + legacyValue: target.dataset.splusTrackingValue || event.button, + id: target.dataset.splusTrackingId || target.id || "Unlabeled Button", + context: target.dataset.splusTrackingContext || "Tracking Link", + value: target.dataset.splusTrackingValue, + }); } let trackedElements = new Set(); @@ -93,7 +169,7 @@ var trackEvent = function (target, action, label = undefined, value = undefined) childList: true, subtree: true }); - + for (let elem of document.querySelectorAll(".splus-track-clicks")) { if (!trackedElements.has(elem)) { elem.addEventListener("click", trackClick); diff --git a/js/api-key.js b/js/api-key.js index 7e494cf8..04139351 100644 --- a/js/api-key.js +++ b/js/api-key.js @@ -19,7 +19,14 @@ let key = currentKey.value; let secret = currentSecret.value; - trackEvent("Change Access", "allowed", "API Key"); + trackEvent("update_setting", { + id: "apistatus", + context: "API Key Page", + value: "allowed", + legacyTarget: "Change Access", + legacyAction: "allowed", + legacyLabel: "API Key" + }); Setting.setValue("apikey", key, () => { Setting.setValue("apisecret", secret, () => { @@ -64,8 +71,8 @@ ]), createElement("div", ["splus-permissions-section"], {}, [ createElement("span", [], { textContent: "If you have any questions, you can" }), - createElement("a", [], { textContent: " view our code on Github", href: "https://github.com/aopell/SchoologyPlus" }), - createElement("span", [], { textContent: " or" }), + createElement("a", ["splus-track-clicks"], { id: "api-key-page-github-link", textContent: " view our code on Github", href: "https://github.com/aopell/SchoologyPlus" }), + createElement("span", ["splus-track-clicks"], { id: "api-key-page-discord-link", textContent: " or" }), createElement("a", [], { textContent: " contact us on Discord", href: "https://discord.schoologypl.us" }), createElement("span", [], { textContent: ". You can change this setting at any time in the Schoology Plus settings menu." }), ]), @@ -97,10 +104,28 @@ createElement("span", [], { textContent: "It looks like your school or district has disabled API Key generation. Unfortunately, this means the above features will not work. The rest of Schoology Plus' features will still work, though!" }), createElement("div", ["splus-permissions-section"], {}, [ - createElement("a", ["splus-permissions-link"], { href: "https://schoologypl.us/docs/faq/api", textContent: "Click Here to Read More" }) + createElement("a", ["splus-permissions-link", "splus-track-clicks"], { href: "https://schoologypl.us/docs/faq/api", textContent: "Click Here to Read More", id: "api-key-disabled-read-more" }) ]) ]) ); + + if (Setting.getValue("apistatus") !== "allowed" && Setting.getValue("apistatus") !== "blocked") { + trackEvent("update_setting", { + id: "apistatus", + context: "API Key Page", + value: "blocked", + legacyTarget: "Change Access", + legacyAction: "blocked", + legacyLabel: "API Key" + }); + Setting.setValue("apistatus", "blocked"); + } + + permElement.appendChild(createButton( + "api-key-disabled-back-to-home", + "Go Back to Homepage", + () => location.pathname = '/' + )); } else { submitButton.parentElement.classList.add("splus-allow-access"); submitButton.value = "Allow Access"; @@ -111,7 +136,14 @@ href: "#", textContent: "Deny Access", onclick: () => { alert("API key access was denied. Please keep in mind many Schoology Plus features will not work correctly with this disabled. You can change this at any time from the Schoology Plus settings menu."); - trackEvent("Change Access", "denied", "API Key"); + trackEvent("update_setting", { + id: "apistatus", + context: "API Key Page", + value: "denied", + legacyTarget: "Change Access", + legacyAction: "denied", + legacyLabel: "API Key" + }); Setting.setValue("apiuser", getUserId(), () => { Setting.setValue("apistatus", "denied", () => { location.pathname = "/"; diff --git a/js/background.js b/js/background.js index 691e979f..35efbd2a 100644 --- a/js/background.js +++ b/js/background.js @@ -46,7 +46,14 @@ chrome.storage.sync.get({ defaultDomain: "app.schoology.com" }, s => { chrome.runtime.onInstalled.addListener(function (details) { // TODO: Open window here to ask new users to select their domain // chrome.tabs.create({ url: "https://schoologypl.us" }) - trackEvent("Runtime onInstalled", details.reason, "Versions"); + trackEvent("perform_action", { + id: "runtime_oninstalled", + value: details.reason, + context: "Versions", + legacyTarget: "Runtime onInstalled", + legacyAction: details.reason, + legacyLabel: "Versions" + }); chrome.contextMenus.create({ "title": "Theme Editor", @@ -73,7 +80,14 @@ chrome.alarms.onAlarm.addListener(onAlarm); Logger.log("Adding notification listener"); chrome.notifications.onClicked.addListener(function (id) { Logger.log("Notification clicked"); - trackEvent(id, "notification click", "Notifications"); + trackEvent("perform_action", { + id: "click", + context: "Notifications", + value: id, + legacyTarget: id, + legacyAction: "notification click", + legacyLabel: "Notifications" + }); chrome.notifications.clear(id, null); switch (id) { case "assignment": @@ -91,7 +105,14 @@ chrome.browserAction.onClicked.addListener(function () { Logger.log("Browser action clicked"); chrome.browserAction.getBadgeText({}, x => { let n = Number.parseInt(x); - trackEvent("Browser Action", n ? `browser action clicked: ${n}` : "browser action clicked: 0", "Notifications"); + trackEvent("button_click", { + id: "main-browser-action-button", + context: "Browser Action", + value: String(n || 0), + legacyTarget: "Browser Action", + legacyAction: n ? `browser action clicked: ${n}` : "browser action clicked: 0", + legacyLabel: "Notifications" + }); Logger.log(`Browser action text: "${x}"`); if (n) chrome.tabs.create({ url: `https://${defaultDomain}/home/notifications` }, null); else chrome.tabs.create({ url: `https://${defaultDomain}` }, null); @@ -229,7 +250,14 @@ function sendNotification(notification, name, count) { } if (!storageContent.notifications || storageContent.notifications == "enabled" || storageContent.notifications == "popup") { chrome.notifications.create(name, notification, null); - trackEvent(name, "shown", "Notifications"); + trackEvent("perform_action", { + id: "shown", + context: "Notifications", + value: name, + legacyTarget: name, + legacyAction: "shown", + legacyLabel: "Notifications" + }); } else { Logger.log("Popup notifications are disabled"); } diff --git a/js/course.js b/js/course.js index 965acaad..3d82a07f 100644 --- a/js/course.js +++ b/js/course.js @@ -70,7 +70,7 @@ let courseSettingsCourseName; ]) ]), createElement("div", ["settings-buttons-wrapper"], undefined, [ - createButton("save-course-settings", "Save Settings", saveCourseSettings), + createButton("save-course-settings", "Save Settings", () => saveCourseSettings()), createElement("div", ["settings-actions-wrapper"], {}, [ createElement("a", ["restore-defaults"], { textContent: "Restore Defaults", onclick: restoreCourseDefaults, href: "#" }) ]), @@ -168,33 +168,57 @@ function getCreatedGradingScale() { function saveCourseSettings(skipSavingGradingScale = false) { let currentValue = Setting.getValue("gradingScales", {}); - - if (skipSavingGradingScale) { + + if (!skipSavingGradingScale) { let scale = getCreatedGradingScale(); if (scale === null) { alert("Values cannot be empty!"); return; } - - if (scale != currentValue[courseIdNumber]) { - trackEvent("gradingScales", "set value", "Course Settings"); + + const shallowCompare = (obj1, obj2) => + Object.keys(obj1).length === Object.keys(obj2).length && + Object.keys(obj1).every(key => + obj2.hasOwnProperty(key) && obj1[key] === obj2[key] + ); + + if (!shallowCompare(scale, currentValue[courseIdNumber])) { + trackEvent("update_setting", { + id: "gradingScales", + context: "Course Settings", + legacyTarget: "gradingScales", + legacyAction: "set value", + legacyLabel: "Course Settings" + }); } - + currentValue[courseIdNumber] = scale; } let currentAliasesValue = Setting.getValue("courseAliases", {}); let newAliasValue = document.getElementById("setting-input-course-alias").value; if (newAliasValue !== currentAliasesValue[courseIdNumber]) { - trackEvent("courseAliases", "set value", "Course Settings"); + trackEvent("update_setting", { + id: "courseAliases", + context: "Course Settings", + legacyTarget: "courseAliases", + legacyAction: "set value", + legacyLabel: "Course Settings" + }); } currentAliasesValue[courseIdNumber] = newAliasValue; let currentQuickLinkValue = Setting.getNestedValue("courseQuickLinks", courseIdNumber); let newQuickLinkValue = document.getElementById("setting-input-course-quicklink").value; if (newQuickLinkValue !== currentQuickLinkValue) { - trackEvent("courseQuickLinks", "set value", "Course Settings"); + trackEvent("update_setting", { + id: "courseQuickLinks", + context: "Course Settings", + legacyTarget: "courseQuickLinks", + legacyAction: "set value", + legacyLabel: "Course Settings" + }); } Setting.setNestedValue("courseQuickLinks", courseIdNumber, newQuickLinkValue); @@ -202,7 +226,14 @@ function saveCourseSettings(skipSavingGradingScale = false) { let iconOverrideSelect = document.getElementById("force-default-icon-splus-courseopt-select"); let overrideValue = iconOverrideSelect.options[iconOverrideSelect.selectedIndex].value; if (overrideValue !== courseIconOverride[courseIdNumber]) { - trackEvent("forceDefaultCourseIcons", `set value: ${overrideValue}`, "Course Settings"); + trackEvent("update_setting", { + id: "forceDefaultCourseIcons", + context: "Course Settings", + value: overrideValue, + legacyTarget: "forceDefaultCourseIcons", + legacyAction: `set value: ${overrideValue}`, + legacyLabel: "Course Settings" + }); } courseIconOverride[courseIdNumber] = overrideValue @@ -229,7 +260,12 @@ function setDefaultScale() { } function restoreCourseDefaults() { - trackEvent("restore-course-defaults", "restore default values", "Course Settings"); + trackEvent("reset_settings", { + context: "Course Settings", + legacyTarget: "restore-course-defaults", + legacyAction: "restore default values", + legacyLabel: "Course Settings" + }); let currentValue = Setting.getValue("gradingScales", {}); delete currentValue[courseIdNumber]; diff --git a/js/courses.js b/js/courses.js index 21e53e89..d335e9c7 100644 --- a/js/courses.js +++ b/js/courses.js @@ -28,7 +28,13 @@ $.contextMenu({ options: { name: "Course Options", callback: function (key, opt) { - trackEvent("Course Options", "click", "Courses Context Menu"); + trackEvent("context_menu_click", { + id: "Course Options", + context: "Courses Page", + legacyTarget: "Course Options", + legacyAction: "click", + legacyLabel: "Courses Context Menu" + }); openModal("course-settings-modal", { courseId: this[0].querySelector(".section-item").id.match(/\d+/)[0], courseName: `${this[0].querySelector(".course-title").textContent}: ${this[0].querySelector(".section-item").textContent}` @@ -39,35 +45,65 @@ $.contextMenu({ materials: { name: "Materials", callback: function (key, opt) { - trackEvent("Materials", "click", "Courses Context Menu"); + trackEvent("context_menu_click", { + id: "Materials", + context: "Courses Page", + legacyTarget: "Materials", + legacyAction: "click", + legacyLabel: "Courses Context Menu" + }); window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].querySelector(".section-item").id.match(/\d+/)[0]}/materials`, "_blank") } }, updates: { name: "Updates", callback: function (key, opt) { - trackEvent("Updates", "click", "Courses Context Menu"); + trackEvent("context_menu_click", { + id: "Updates", + context: "Courses Page", + legacyTarget: "Updates", + legacyAction: "click", + legacyLabel: "Courses Context Menu" + }); window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].querySelector(".section-item").id.match(/\d+/)[0]}/updates`, "_blank") } }, student_grades: { name: "Grades", callback: function (key, opt) { - trackEvent("Grades", "click", "Courses Context Menu"); + trackEvent("context_menu_click", { + id: "Grades", + context: "Courses Page", + legacyTarget: "Grades", + legacyAction: "click", + legacyLabel: "Courses Context Menu" + }); window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].querySelector(".section-item").id.match(/\d+/)[0]}/student_grades`, "_blank") } }, mastery: { name: "Mastery", callback: function (key, opt) { - trackEvent("Mastery", "click", "Courses Context Menu"); + trackEvent("context_menu_click", { + id: "Mastery", + context: "Courses Page", + legacyTarget: "Mastery", + legacyAction: "click", + legacyLabel: "Courses Context Menu" + }); window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].querySelector(".section-item").id.match(/\d+/)[0]}/mastery`, "_blank") } }, members: { name: "Members", callback: function (key, opt) { - trackEvent("Members", "click", "Courses Context Menu"); + trackEvent("context_menu_click", { + id: "Members", + context: "Courses Page", + legacyTarget: "Members", + legacyAction: "click", + legacyLabel: "Courses Context Menu" + }); window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].querySelector(".section-item").id.match(/\d+/)[0]}/members`, "_blank") } } diff --git a/js/grades.js b/js/grades.js index 0a6400fc..e2691791 100644 --- a/js/grades.js +++ b/js/grades.js @@ -1,1633 +1,1825 @@ -(async function () { - // Wait for loader.js to finish running - while (!window.splusLoaded) { - await new Promise(resolve => setTimeout(resolve, 10)); - } - await loadDependencies("grades", ["all"]); -})(); - -const timeout = ms => new Promise(res => setTimeout(res, ms)); -const BUG_REPORT_FORM_LINK = "https://docs.google.com/forms/d/e/1FAIpQLScF1_MZofOWT9pkWp3EfKSvzCPpyevYtqbAucp1K5WKGlckiA/viewform?entry.118199430="; -const SINGLE_COURSE = window.location.href.includes("/course/"); -var editDisableReason = null; -var invalidCategories = []; - -function addEditDisableReason(err = "Unknown Error", causedBy403 = false, causedByNoApiKey = false) { - if (!editDisableReason) { - editDisableReason = { version: chrome.runtime.getManifest().version, errors: [], allCausedBy403: causedBy403, causedByNoApiKey: causedByNoApiKey }; - } - editDisableReason.errors.push(err); - editDisableReason.allCausedBy403 = editDisableReason.allCausedBy403 && causedBy403; - editDisableReason.causedByNoApiKey = editDisableReason.causedByNoApiKey || causedByNoApiKey; - Logger.debug(editDisableReason, err, causedBy403, causedByNoApiKey); -} - -$.contextMenu({ - selector: ".gradebook-course-title", - items: { - options: { - name: "Course Options", - callback: function (key, opt) { - trackEvent("Course Options", "click", "Grades Context Menu"); - openModal("course-settings-modal", { - courseId: this[0].parentElement.id.match(/\d+/)[0], - courseName: this[0].querySelector("a span:nth-child(3)") ? this[0].querySelector("a span:nth-child(2)").textContent : this[0].innerText.split('\n')[0] - }); - } - }, - grades: { - name: "Change Grading Scale", - callback: function (key, opt) { - trackEvent("Change Grading Scale", "click", "Grades Context Menu"); - openModal("course-settings-modal", { - courseId: this[0].parentElement.id.match(/\d+/)[0], - courseName: this[0].querySelector("a span:nth-child(3)") ? this[0].querySelector("a span:nth-child(2)").textContent : this[0].innerText.split('\n')[0] - }); - } - }, - separator: "-----", - materials: { - name: "Materials", - callback: function (key, opt) { - trackEvent("Materials", "click", "Grades Context Menu"); - window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].parentElement.id.match(/\d+/)[0]}/materials`, "_blank") - } - }, - updates: { - name: "Updates", - callback: function (key, opt) { - trackEvent("Updates", "click", "Grades Context Menu"); - window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].parentElement.id.match(/\d+/)[0]}/updates`, "_blank") - } - }, - student_grades: { - name: "Grades", - callback: function (key, opt) { - trackEvent("Grades", "click", "Grades Context Menu"); - window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].parentElement.id.match(/\d+/)[0]}/student_grades`, "_blank") - } - }, - mastery: { - name: "Mastery", - callback: function (key, opt) { - trackEvent("Mastery", "click", "Grades Context Menu"); - window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].parentElement.id.match(/\d+/)[0]}/mastery`, "_blank") - } - }, - members: { - name: "Members", - callback: function (key, opt) { - trackEvent("Members", "click", "Grades Context Menu"); - window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].parentElement.id.match(/\d+/)[0]}/members`, "_blank") - } - } - } -}); - -var fetchQueue = []; -(async function () { - Logger.log("Running Schoology Plus grades page improvement script"); - - let inner = document.getElementById("main-inner") || document.getElementById("content-wrapper"); - let courses = inner.getElementsByClassName("gradebook-course"); - let coursesByPeriod = []; - let gradesModified = false; - - let upperPeriodSortBound = 20; - - let courseLoadTasks = []; - - for (let course of courses) { - courseLoadTasks.push((async function () { - let title = course.querySelector(".gradebook-course-title"); - let summary = course.querySelector(".summary-course"); - let courseId = title.parentElement.id.match(/\d+/)[0]; - let courseGrade; - if (summary) { - courseGrade = summary.querySelector(".awarded-grade"); - } else { - try { - let finalGradeArray = (await fetchApiJson(`users/${getUserId()}/grades?section_id=${courseId}`)).section[0].final_grade; - courseGrade = createElement("span", [], { textContent: `${finalGradeArray[finalGradeArray.length - 1].grade.toString()}%` }); - } catch { - courseGrade = null; - } - } - let table = course.querySelector(".gradebook-course-grades").firstElementChild; - let grades = table.firstElementChild; - let categories = Array.from(grades.getElementsByClassName("category-row")); - let rows = Array.from(grades.children); - let periods = Array.from(course.getElementsByClassName("period-row")).filter(x => !x.textContent.includes("(no grading period)")); - - let classPoints = 0; - let classTotal = 0; - let addMoreClassTotal = true; - - // if there's no PERIOD \d string in the course name, match will return null; in that case, use the array [null, i++] - // OR is lazy, so the ++ won't trigger unnecessarily; upperPeriodSortBound is our array key, and we use it to give a unique index (after all course) to periodless courses - coursesByPeriod[Number.parseInt((title.textContent.match(/\b[Pp][Ee]?[Rr]?[Ii]?[Oo]?[Dd]?\s*(\d+)/) || [null, upperPeriodSortBound++])[1])] = course; - - // Fix width of assignment columns - table.appendChild(createElement("colgroup", [], {}, [ - createElement("col", ["assignment-column"]), - createElement("col", ["points-column"]), - createElement("col", ["comments-column"]) - ])); - - let kabobMenuButton = createElement("span", ["grades-kabob-menu"], { - textContent: "⠇", - onclick: function (event) { - $(title).contextMenu({ x: event.pageX, y: event.pageY }); - // hacky way to prevent the course from expanding - title.click(); - } - }); - let grade = createElement("span", ["awarded-grade", "injected-title-grade", courseGrade ? "grade-active-color" : "grade-none-color"], { textContent: "LOADING" }); - title.appendChild(kabobMenuButton); - title.appendChild(grade); - - for (let period of periods) { - let periodPoints = 0; - let periodTotal = 0; - let invalidatePerTotal = false; - - for (let category of categories.filter(x => period.dataset.id == x.dataset.parentId)) { - try { - let assignments = rows.filter(x => category.dataset.id == x.dataset.parentId); - let sum = 0; - let max = 0; - let processAssignment = async function (assignment) { - let maxGrade = assignment.querySelector(".max-grade"); - let score = assignment.querySelector(".rounded-grade") || assignment.querySelector(".rubric-grade-value"); - if (score) { - let assignmentScore = Number.parseFloat(score.textContent); - let assignmentMax = Number.parseFloat(maxGrade.textContent.substring(3)); - - if (!assignment.classList.contains("dropped")) { - sum += assignmentScore; - max += assignmentMax; - } - - let newGrade = document.createElement("span"); - prepareScoredAssignmentGrade(newGrade, assignmentScore, assignmentMax); - - // td-content-wrapper - maxGrade.parentElement.appendChild(document.createElement("br")); - maxGrade.parentElement.appendChild(newGrade); - } - else { - queueNonenteredAssignment(assignment, courseId); - } - if (assignment.querySelector(".missing")) { - // get denominator for missing assignment - let p = assignment.querySelector(".injected-assignment-percent"); - p.textContent = "0%"; - p.title = "Assignment missing"; - Logger.log(`Fetching max points for assignment ${assignment.dataset.id.substr(2)}`); - - let json = await fetchApiJson(`users/${getUserId()}/grades?section_id=${courseId}`); - - if (json.section.length === 0) { - throw new Error("Assignment details could not be read"); - } - - const assignments = json.section[0].period.reduce((prevVal, curVal) => prevVal.concat(curVal.assignment), []);//combines the assignment arrays from each period - let pts = Number.parseFloat(assignments.filter(x => x.assignment_id == assignment.dataset.id.substr(2))[0].max_points); - if (!assignment.classList.contains("dropped")) { - max += pts; - Logger.log(`Max points for assignment ${assignment.dataset.id.substr(2)} is ${pts}`); - } - } - //assignment.style.padding = "7px 30px 5px"; - //assignment.style.textAlign = "center"; - - // kabob menu - let commentsContentWrapper = assignment.querySelector(".comment-column").firstElementChild; - let kabobMenuButton = createElement("span", ["kabob-menu"], { - textContent: "⠇", - onclick: function (event) { - $(assignment).contextMenu({ x: event.pageX, y: event.pageY }); - } - }); - kabobMenuButton.dataset.parentId = assignment.dataset.parentId; - - let editEnableCheckbox = document.getElementById("enable-modify"); - - // not created yet and thus editing disabled, or created the toggle but editing disabled - if (!editEnableCheckbox || !editEnableCheckbox.checked) { - kabobMenuButton.classList.add("hidden"); - } - - commentsContentWrapper.insertAdjacentElement("beforeend", kabobMenuButton); - if (commentsContentWrapper.querySelector(".comment")) { - // Fixes kabob display issues with long comments - commentsContentWrapper.style.display = "flex"; - // Fixes kabob display issues with short comments - commentsContentWrapper.style.justifyContent = "space-between"; - } - - let createAddAssignmentUi = async function () { - let addAssignmentThing = createAddAssignmentElement(category); - - if (assignment.classList.contains("hidden")) { - addAssignmentThing.classList.add("hidden"); - } - - assignment.insertAdjacentElement('afterend', addAssignmentThing); - await processAssignment(addAssignmentThing); - - return addAssignmentThing; - }; - - // add UI for grade virtual editing - let gradeWrapper = assignment.querySelector(".grade-wrapper"); - - let checkbox = document.getElementById("enable-modify"); - let gradeAddEditHandler = null; - let editGradeImg = createElement("img", ["grade-edit-indicator"], { - src: chrome.runtime.getURL("imgs/edit-pencil.svg"), - width: 12, - style: `display: ${checkbox && checkbox.checked ? "unset" : "none"};` - }); - editGradeImg.dataset.parentId = assignment.dataset.parentId; - if (assignment.classList.contains("grade-add-indicator")) { - // when this is clicked, if the edit was successful, we don't have to worry about making our changes reversible cleanly - // the reversal takes the form of a page refresh once grades have been changed - let hasHandledGradeEdit = false; - gradeAddEditHandler = async function () { - if (hasHandledGradeEdit) { - return; - } - - assignment.classList.remove("grade-add-indicator"); - assignment.classList.remove("last-row-of-tier"); - - assignment.classList.add("added-fake-assignment"); - trackEvent("assignment", "create-fake", "What-If Grades"); - - let assignmentTitle = assignment.getElementsByClassName("title")[0].firstElementChild; - assignmentTitle.textContent = "Added Assignment (Click to Rename)"; - assignmentTitle.classList.add("editable-assignment-name"); - assignmentTitle.contentEditable = "true"; - assignmentTitle.addEventListener("keydown", event => { - if (event.which === 13) { - event.target.blur(); - window.getSelection().removeAllRanges(); - } - }); - - let newAddAssignmentPlaceholder = await createAddAssignmentUi(); - newAddAssignmentPlaceholder.style.display = "table-row"; - - hasHandledGradeEdit = true; - }; - } - // edit image - editGradeImg.addEventListener("click", createEditListener(assignment, gradeWrapper.parentElement, category, period, gradeAddEditHandler)); - gradeWrapper.appendChild(editGradeImg); - // edit text - const gradeText = assignment.querySelector(".awarded-grade") || assignment.querySelector(".no-grade"); - gradeText.addEventListener("click", createEditListener(assignment, gradeWrapper.parentElement, category, period, gradeAddEditHandler)); - - if (assignment.classList.contains("last-row-of-tier") && !assignment.classList.contains("grade-add-indicator")) { - await createAddAssignmentUi(); - } - }; - - let invalidateCatTotal = false; - - for (let assignment of assignments) { - try { - await processAssignment(assignment); - } catch (err) { - if (err === "noapikey") { - addEditDisableReason({ error: { message: err, name: err, stack: undefined, full: JSON.stringify(err) }, courseId, course: title.textContent, assignment: assignment.textContent }, false, true); - } else { - if (!assignment.classList.contains("dropped") && assignment.querySelector(".missing")) { - // consequential failure: our denominator is invalid - invalidateCatTotal = true; - invalidCategories.push(category.dataset.id); - - if ("status" in err && err.status === 403) { - addEditDisableReason({ - error: { - message: err.error, - status: err.status - }, - courseId, - course: title.textContent, - assignment: assignment.textContent - }, true, err === "noapikey"); - continue; - } - } - - addEditDisableReason({ error: { message: err.message, name: err.name, stack: err.stack, full: JSON.stringify(err) }, courseId, course: title.textContent, assignment: assignment.textContent }, false, err === "noapikey"); - } - Logger.error("Error loading assignment for " + courseId + ": ", assignment, err); - } - } - - if (assignments.length === 0) { - category.querySelector(".grade-column").classList.add("grade-column-center"); - - let createAddAssignmentUi = async function () { - let addAssignmentThing = createAddAssignmentElement(category); - - category.insertAdjacentElement('afterend', addAssignmentThing); - await processAssignment(addAssignmentThing); - - return addAssignmentThing; - }; - - let categoryArrow = createElement("img", ["expandable-icon-grading-report", "injected-empty-category-expand-icon"], { src: "/sites/all/themes/schoology_theme/images/expandable-sprite.png" }); - category.querySelector("th .td-content-wrapper").prepend(categoryArrow); - - if (!category.classList.contains("hidden")) { - await createAddAssignmentUi(); - } - } - - let gradeText = category.querySelector(".awarded-grade") || category.querySelector(".no-grade"); - if (!gradeText) { - gradeText = createElement("span", ["awarded-grade"], { textContent: "—" }); - category.querySelector(".grade-column .td-content-wrapper").appendChild(gradeText); - setGradeText(gradeText, sum, max, category); - recalculateCategoryScore(category, 0, 0, false); - } else { - setGradeText(gradeText, sum, max, category); - } - - gradeText.classList.remove("no-grade"); - gradeText.classList.add("awarded-grade"); - - if (invalidateCatTotal) { - let catMaxElem = gradeText.parentElement.querySelector(".grade-column-center .max-grade"); - catMaxElem.classList.add("max-grade-show-error"); - invalidatePerTotal = true; - } - - let weightText = category.querySelector(".percentage-contrib"); - if (addMoreClassTotal) { - if (!weightText) { - classPoints += sum; - classTotal += max; - periodPoints += sum; - periodTotal += max; - } else if (weightText.textContent == "(100%)") { - classPoints = sum; - classTotal = max; - periodPoints = sum; - periodTotal = max; - addMoreClassTotal = false; - } else { - // there are weighted categories that aren't 100%, abandon our calculation - classPoints = 0; - classTotal = 0; - periodPoints = 0; - periodTotal = 0; - addMoreClassTotal = false; - } - } - } catch (err) { - addEditDisableReason({ error: JSON.stringify(err, Object.getOwnPropertyNames(err)), category: category.textContent }, false, err === "noapikey") - } - } - - - let gradeText = period.querySelector(".awarded-grade") || period.querySelector(".no-grade"); - if (!gradeText) { - gradeText = createElement("span", ["awarded-grade"], { textContent: "—" }); - period.querySelector(".grade-column .td-content-wrapper").appendChild(gradeText); - setGradeText(gradeText, periodPoints, periodTotal, period, periodTotal === 0); - recalculatePeriodScore(period, 0, 0, false); - } else { - setGradeText(gradeText, periodPoints, periodTotal, period, periodTotal === 0); - } - - // add weighted indicator to the course section row - if (classTotal === 0 && !addMoreClassTotal) { - let newElem = createElement("span", ["splus-weighted-gradebook-indicator"], { - textContent: "[Weighted]" - }); - let periodPercent = period.querySelector("span.percentage-contrib"); - if (periodPercent) { - periodPercent.insertAdjacentElement('beforebegin', newElem); - } - } - - if (invalidatePerTotal && classTotal !== 0) { - let perMaxElem = gradeText.parentElement.querySelector(".grade-column-center .max-grade"); - perMaxElem.classList.add("max-grade-show-error"); - } - } - - grade.textContent = courseGrade ? courseGrade.textContent : "—"; - addLetterGrade(grade, courseId); - - // FIXME this is duplicated logic to get the div.gradebook-course given the period row - let gradebookCourseContainerDiv = periods[0]; - - - // FIXME null coalesence hack - // .parentElement.parentElement.parentElement.parentElement - (function () { - for (let i = 0; i < 4; i++) { - if (gradebookCourseContainerDiv != null) { - gradebookCourseContainerDiv = gradebookCourseContainerDiv.parentElement; - } - } - })(); - - // points needed for (next grade) / point buffer from (next lowest grade) logic - if (gradebookCourseContainerDiv != null && classTotal != 0 && periods.length === 1) { - let summaryPointsContainer = gradebookCourseContainerDiv.querySelector(".gradebook-course-grades .summary-course"); - - let gradeScale = Setting.getValue("getGradingScale")(courseId); - let myPercentage = (classPoints / classTotal) * 100; - - if (summaryPointsContainer) { - let gradeScaleSorted = Object.keys(gradeScale).sort((a, b) => b - a).map(function (p) { return { symbol: gradeScale[p], minGrade: +p }; }); - - let myGradeIndex = -1; - - for (let i = 0; i < gradeScaleSorted.length; i++) { - if (myPercentage >= gradeScaleSorted[i].minGrade) { - myGradeIndex = i; - break; - } - } - - function roundDecimal(num, dec) { - let intPart = Math.floor(num); - let floatPart = num - intPart; - return intPart + (Math.round(floatPart * Math.pow(10, dec)) / Math.pow(10, dec)); - } - - function createSPlusDisclaimerImage() { - // rationale: we want to be very explicit whenever we're modifying anything in the bottom "Course Grade" box - // we put our tweaks here in the first place because it makes sense in the UI, and because it doesn't change with grade edits - // yet we want to be clear this is unofficial - // TODO discuss potentially better alternatives to either this particular or any img in general for making this clarification - return createSvgLogo("splus-coursegradebox-taint"); - } - - // points needed for next highest grade - if (myGradeIndex > 0) { - let targetGrade = gradeScaleSorted[myGradeIndex - 1]; - let targetPts = (targetGrade.minGrade / 100) * classTotal; - let neededPts = roundDecimal(targetPts - classPoints, 2); - - summaryPointsContainer.appendChild(createElement("div", ["total-points-wrapper"], {}, [ - createSPlusDisclaimerImage(), - createElement("span", ["total-points-title"], { textContent: "Points Needed:" }), - createElement("span", ["total-points-awarded"], { textContent: neededPts }), - createElement("span", ["total-points-possible"], { textContent: " for " + targetGrade.symbol }) - ])); - } - - if (myGradeIndex < gradeScaleSorted.length - 1) { - let targetGrade = gradeScaleSorted[myGradeIndex + 1]; - let targetPts = (gradeScaleSorted[myGradeIndex].minGrade / 100) * classTotal; - let bufferPts = roundDecimal(classPoints - targetPts, 2); - - summaryPointsContainer.appendChild(createElement("div", ["total-points-wrapper"], {}, [ - createSPlusDisclaimerImage(), - createElement("span", ["total-points-title"], { textContent: "Point Buffer:" }), - createElement("span", ["total-points-awarded"], { textContent: bufferPts }), - createElement("span", ["total-points-possible"], { textContent: " from " + targetGrade.symbol }) - ])); - } - } - } - - // Remove (no grading period) - if (isLAUSD()) { // To avoid making assumptions about how other schools use Schoology - for (let i = 1; i < periods.length; i++) { - periods[i].remove(); - } - } - })()); - } - - if (!document.location.search.includes("past") || document.location.search.split("past=")[1] != 1) { - let timeRow = document.getElementById("past-selector"); - let gradeModifLabelFirst = true; - if (timeRow == null) { - // basically a verbose null propagation - // timeRow = document.querySelector(".content-top-upper")?.insertAdjacentElement('afterend', document.createElement("div")) - let contentTopUpper = document.querySelector(".content-top-upper"); - if (contentTopUpper) { - timeRow = contentTopUpper.insertAdjacentElement('afterend', document.createElement("div")); - } - } - if (timeRow == null) { - let downloadBtn = document.querySelector("#main-inner .download-grade-wrapper"); - if (downloadBtn) { - let checkboxHolder = document.createElement("span"); - checkboxHolder.id = "splus-gradeedit-checkbox-holder"; - downloadBtn.prepend(checkboxHolder); - - downloadBtn.classList.add("splus-gradeedit-checkbox-holder-wrapper"); - - timeRow = checkboxHolder; - gradeModifLabelFirst = false; - } - } - - let timeRowLabel = createElement("label", ["modify-label"], { - htmlFor: "enable-modify" - }, [ - createElement("span", [], { textContent: "Enable what-if grades" }), - createElement("a", ["splus-grade-help-btn"], { - href: "https://schoologypl.us/docs/grades", - target: "_blank" - }, [createElement("span", ["icon-help"])]) - ]); - - if (gradeModifLabelFirst) { - timeRow.appendChild(timeRowLabel); - } - - timeRow.appendChild(createElement("input", ["splus-track-clicks"], { - type: "checkbox", - id: "enable-modify", - dataset: { - splusTrackingLabel: "What-If Grades" - }, - onclick: function () { - let normalAssignRClickSelector = ".item-row:not(.dropped):not(.grade-add-indicator):not(.added-fake-assignment)"; - let addedAssignRClickSelector = ".item-row.added-fake-assignment:not(.dropped):not(.grade-add-indicator)"; - let droppedAssignRClickSelector = ".item-row.dropped:not(.grade-add-indicator)"; - - // any state change when editing has been disabled - if (Setting.getValue("apistatus") === "denied") { - if (confirm("This feature requires access to your Schoology API Key, which you have denied. Would you like to enable access?")) { - trackEvent("api-denied-popup", "go-to-enable", "What-If Grades"); - location.pathname = "/api"; - } else { - trackEvent("api-denied-popup", "keep-disabled", "What-If Grades"); - } - } - else if (editDisableReason && editDisableReason.causedByNoApiKey) { - location.pathname = "/api"; - } - else if (editDisableReason && !editDisableReason.allCausedBy403) { - Logger.error("Editing disabled due to error", editDisableReason); - - if (confirm("Grade editing has been disabled due to an error. If you are trying to use What If Grades on the grade report page, try going to an individual class gradebook instead. Would you like to report this issue? (It will help us fix it faster!)")) { - window.open(`${BUG_REPORT_FORM_LINK}${encodeURIComponent(JSON.stringify(editDisableReason))}`, "_blank"); - } - - document.getElementById("enable-modify").checked = false; - } - // enabling editing - else if (document.getElementById("enable-modify").checked) { - if (editDisableReason && editDisableReason.allCausedBy403) { - if (confirm("WARNING!!!\n\nYou have one or more missing assignments for which the total points are unknown due to restrictions put in place by your teacher. Grade editing may work in some categories if this is a weighted gradebook, however it will be disabled in others. We are working on a fix for this issue, but until then please click 'OK' to submit a bug report so we can gague how large this problem is. Thank you!")) { - window.open(`${BUG_REPORT_FORM_LINK}${encodeURIComponent(JSON.stringify(editDisableReason))}`, "_blank"); - } - } - - for (let edit of document.getElementsByClassName("grade-edit-indicator")) { - if (invalidCategories.includes(edit.dataset.parentId)) continue; - - edit.style.display = "unset"; - } - for (let edit of document.getElementsByClassName("grade-add-indicator")) { - if (invalidCategories.includes(edit.dataset.parentId)) continue; - - edit.style.display = "table-row"; - if (edit.previousElementSibling.classList.contains("item-row") && edit.previousElementSibling.classList.contains("last-row-of-tier")) { - edit.previousElementSibling.classList.remove("last-row-of-tier"); - } - } - for (let arrow of document.getElementsByClassName("injected-empty-category-expand-icon")) { - arrow.style.visibility = "visible"; - } - - let calculateMinimumGrade = function (element, desiredGrade) { - let gradeColContentWrap = element.querySelector(".grade-wrapper").parentElement; - - removeExceptionState(element, gradeColContentWrap); - - // TODO refactor the grade extraction - let noGrade = gradeColContentWrap.querySelector(".no-grade"); - let score = gradeColContentWrap.querySelector(".rounded-grade") || gradeColContentWrap.querySelector(".rubric-grade-value") || gradeColContentWrap.querySelector(".no-grade"); - let maxGrade = gradeColContentWrap.querySelector(".max-grade"); - let scoreVal = 0; - let maxVal = 0; - noGrade = noGrade == score; - - if (score && maxGrade) { - scoreVal = Number.parseFloat(score.textContent); - maxVal = Number.parseFloat(maxGrade.textContent.substring(3)); - } else if (element.querySelector(".exception-icon.missing")) { - let scoreValues = maxGrade.textContent.split("/"); - scoreVal = Number.parseFloat(scoreValues[0]); - maxVal = Number.parseFloat(scoreValues[1]); - } - - if (Number.isNaN(scoreVal)) { - scoreVal = 0; - score.classList.add("rounded-grade"); - score.classList.remove("no-grade"); - } - - if (!gradeColContentWrap.querySelector(".modified-score-percent-warning")) { - //gradeColContentWrap.getElementsByClassName("injected-assignment-percent")[0].style.paddingRight = "0"; - gradeColContentWrap.appendChild(generateScoreModifyWarning()); - gradesModified = true; - } - - let catId = element.dataset.parentId; - let catRow = Array.prototype.find.call(element.parentElement.getElementsByTagName("tr"), e => e.dataset.id == catId); - - let perId = catRow.dataset.parentId; - let perRow = Array.prototype.find.call(element.parentElement.getElementsByTagName("tr"), e => e.dataset.id == perId); - - let deltaScore = 0; - - // TODO refactor - // if the period (course) has a point value (unweighted), just work from there - let awardedPeriodPoints = perRow.querySelector(".grade-column-center"); - if (awardedPeriodPoints && awardedPeriodPoints.textContent.trim().length !== 0) { - // awarded grade in our constructed element contains both rounded and max - let perScoreElem = awardedPeriodPoints.querySelector(".rounded-grade"); - let perMaxElem = awardedPeriodPoints.querySelector(".max-grade"); - let perScore = Number.parseFloat(perScoreElem.textContent); - let perMax = Number.parseFloat(perMaxElem.textContent.substring(3)); - - if (noGrade) { - perMax += maxVal; - } - - // (perScore + x) / perMax = desiredGrade - // solve for x: - deltaScore = (desiredGrade * perMax) - perScore; - } else { - // weighted - - // get this out of the way so it doesn't ruin later calculations - if (noGrade) { - recalculateCategoryScore(catRow, 0, maxVal); - recalculatePeriodScore(perRow, 0, maxVal); - noGrade = false; - } - - // get category score and category weight - let awardedCategoryPoints = catRow.querySelector(".rounded-grade").parentNode; - let catScoreElem = awardedCategoryPoints.querySelector(".rounded-grade"); - let catMaxElem = awardedCategoryPoints.querySelector(".max-grade"); - let catScore = Number.parseFloat(catScoreElem.textContent); - let catMax = Number.parseFloat(catMaxElem.textContent.substring(3)); - - if (noGrade) { - catMax += maxVal; - } - - // TODO refactor - let total = 0; - let totalPercentWeight = 0; - let catWeight = 0; // 0 to 1 - for (let category of perRow.parentElement.querySelectorAll(`.category-row[data-parent-id="${perRow.dataset.id}"]`)) { - let weightPercentElement = category.getElementsByClassName("percentage-contrib")[0]; - if (!weightPercentElement) { - continue; - } - - let weightPercent = weightPercentElement.textContent; - let col = category.getElementsByClassName("grade-column-right")[0]; - - let colMatch = col ? col.textContent.match(/(\d+\.?\d*)%/) : null; - - if (colMatch) { - let scorePercent = Number.parseFloat(colMatch[1]); - if ((scorePercent || scorePercent === 0) && !Number.isNaN(scorePercent)) { - total += (weightPercent.slice(1, -2) / 100) * scorePercent; - let weight = Number.parseFloat(weightPercent.slice(1, -2)); - totalPercentWeight += weight; - if (category.dataset.id == catId) { - catWeight = weight / 100; - } - } - } - } - - totalPercentWeight /= 100; - - // if only some categories have assignments, adjust the total accordingly - // if weights are more than 100, this assumes that it's correct as intended (e.c.), I won't mess with it - if (totalPercentWeight > 0 && totalPercentWeight < 1) { - // some categories are specified, but weights don't quite add to 100 - // scale up known grades - total /= totalPercentWeight; - catWeight /= totalPercentWeight; - // epsilon because floating point - } else if (totalPercentWeight < 0.00001) { - total = 100; - } - - // normalize total: 0 to 1+ [e.c.], total course score - total /= 100; - - // calculate the worth in percentage points of one point in our category - let catPointWorth = catWeight / catMax; - - // total + (catPointWorth*deltaScore) = desiredGrade - // solve: - deltaScore = (desiredGrade - total) / catPointWorth; - } - - if (deltaScore < -scoreVal) { - deltaScore = -scoreVal; - } - - // ?: Using Math.ceil ensures finalGrade >= desiredGrade when possible - deltaScore = Math.ceil(deltaScore * 100) / 100; - - const finalGrade = Math.round((scoreVal + deltaScore) * 100) / 100; - if (score) { - // TODO refactor: we already have our DOM elements - score.title = finalGrade; - score.textContent = finalGrade; - } - - prepareScoredAssignmentGrade(element.querySelector(".injected-assignment-percent"), finalGrade, maxVal); - recalculateCategoryScore(catRow, deltaScore, noGrade ? maxVal : 0); - recalculatePeriodScore(perRow, deltaScore, noGrade ? maxVal : 0); - }; - - let dropGradeThis = function () { - trackEvent("assignment", "drop", "What-If Grades"); - this[0].classList.add("dropped"); - // alter grade - let gradeColContentWrap = this[0].querySelector(".grade-wrapper").parentElement; - // TODO refactor the grade extraction - let score = gradeColContentWrap.querySelector(".rounded-grade") || gradeColContentWrap.querySelector(".rubric-grade-value"); - let maxGrade = gradeColContentWrap.querySelector(".max-grade"); - let scoreVal = 0; - let maxVal = 0; - - if (score && maxGrade) { - scoreVal = Number.parseFloat(score.textContent); - maxVal = Number.parseFloat(maxGrade.textContent.substring(3)); - } else if (this[0].classList.contains("contains-exception")) { - let scoreValues = maxGrade.textContent.split("/"); - scoreVal = Number.parseFloat(scoreValues[0]); - maxVal = Number.parseFloat(scoreValues[1]); - if (Number.isNaN(scoreVal)) { - // exception states are 0 or -, always - // this might be NaN because it's parsing a space - scoreVal = 0; - } - if (!this[0].querySelector(".exception-icon.missing")) { - // non-missing exception means uncounted (incomplete or excused) - // set scores to 0 so we make no mathematical change - scoreVal = 0; - maxVal = 0; - } - } - - if (!gradeColContentWrap.querySelector(".modified-score-percent-warning")) { - //gradeColContentWrap.getElementsByClassName("injected-assignment-percent")[0].style.paddingRight = "0"; - gradeColContentWrap.appendChild(generateScoreModifyWarning()); - gradesModified = true; - } - - let catId = this[0].dataset.parentId; - let catRow = Array.prototype.find.call(this[0].parentElement.getElementsByTagName("tr"), e => e.dataset.id == catId); - recalculateCategoryScore(catRow, -scoreVal, -maxVal); - - let perId = catRow.dataset.parentId; - let perRow = Array.prototype.find.call(this[0].parentElement.getElementsByTagName("tr"), e => e.dataset.id == perId); - recalculatePeriodScore(perRow, -scoreVal, -maxVal); - }; - - let undroppedAssignItemSet = { - drop: { - name: "Drop", - callback: dropGradeThis - }, - delete: { - name: "Delete", - callback: function () { - dropGradeThis.bind(this)(); - // shouldn't need to worry about any last-row-of-tier stuff because this will always be followed by an Add Assignment indicator - this[0].remove(); - } - }, - separator: "-----", - calculateMinGrade: { - name: "Calculate Minimum Grade", - callback: function (key, opt) { - // TODO refactor grade extraction - // get course letter grade - let catId = this[0].dataset.parentId; - let catRow = Array.prototype.find.call(this[0].parentElement.getElementsByTagName("tr"), e => e.dataset.id == catId); - let perId = catRow.dataset.parentId; - let perRow = Array.prototype.find.call(this[0].parentElement.getElementsByTagName("tr"), e => e.dataset.id == perId); - - // TODO refactor - // (this) tr -> tbody -> table -> div.gradebook-course-grades -> relevant div - let courseId = Number.parseInt(/course-(\d+)$/.exec(this[0].parentElement.parentElement.parentElement.parentElement.id)[1]); - - let gradingScale = Setting.getValue("getGradingScale")(courseId); - - let courseGrade = getLetterGrade(gradingScale, Number.parseFloat(/\d+(\.\d+)%/.exec(perRow.querySelector(".grade-column-right").firstElementChild.textContent)[0].slice(0, -1))); - - let desiredPercentage = 0.9; - - // letter to percent - "in" for property enumeration - for (let gradeValue in gradingScale) { - if (gradingScale[gradeValue] == courseGrade) { - desiredPercentage = Number.parseFloat(gradeValue) / 100; - break; - } - } - - trackEvent("assignment", "calc-min", "What-If Grades"); - calculateMinimumGrade(this[0], desiredPercentage); - }, - items: {} - } - }; - - let undroppedAssignContextMenuItems = { - drop: undroppedAssignItemSet.drop, - separator: undroppedAssignItemSet.separator, - calculateMinGrade: undroppedAssignItemSet.calculateMinGrade - }; - - for (let courseElement of document.getElementsByClassName("gradebook-course")) { - let baseContextMenuObject = {}; - let calcMinFor = {}; - baseContextMenuObject.items = {}; - Object.assign(baseContextMenuObject.items, undroppedAssignContextMenuItems); - baseContextMenuObject.items.calculateMinGrade = {}; - Object.assign(baseContextMenuObject.items.calculateMinGrade, undroppedAssignContextMenuItems.calculateMinGrade); - baseContextMenuObject.items.calculateMinGrade.items = calcMinFor; - - baseContextMenuObject.selector = "#" + courseElement.id + " "; - - let courseId = /\d+$/.exec(courseElement.id)[0]; - - // TODO if grade scale is updated while this page is loaded (i.e. after this code runs) what to do - let gradingScale = Setting.getValue("getGradingScale")(courseId); - - for (let gradeValue of Object.keys(gradingScale).sort((a, b) => b - a)) { - let letterGrade = gradingScale[gradeValue]; - calcMinFor["calculateMinGradeFor" + gradeValue] = { - name: "For " + letterGrade + " (" + gradeValue + "%)", - callback: function (key, opt) { - trackEvent("assignment", `calc-min-for-${letterGrade}`, "What-If Grades"); - calculateMinimumGrade(this[0], Number.parseFloat(gradeValue) / 100); - } - }; - } - - calcMinFor.separator1 = "-----"; - - calcMinFor.calculateMinGradeForCustom = { - name: "For Custom Value", - callback: function (key, opt) { - trackEvent("assignment", `calc-min-for-custom`, "What-If Grades"); - - let value = prompt("Please enter a grade to calculate for (a number on the scale of 0 to 100)"); - - if (!Number.isNaN(value) && !Number.isNaN(Number.parseFloat(value))) { - // if a number, calculate - calculateMinimumGrade(this[0], Number.parseFloat(value) / 100); - } else { - alert("Invalid number") - } - } - }; - - calcMinFor.separator2 = "-----"; - calcMinFor.courseOptions = { - name: "Change Grade Boundaries", - callback: function () { - let courseElem = this[0].closest(".gradebook-course"); - let titleElem = SINGLE_COURSE ? document.querySelector(".page-title") : courseElem.querySelector(".gradebook-course-title"); - trackEvent("assignment", "change-boundaries", "What-If Grades"); - openModal("course-settings-modal", { - courseId: courseElem.id.match(/\d+/)[0], - courseName: titleElem.querySelector("a span:nth-child(3)") ? titleElem.querySelector("a span:nth-child(2)").textContent : titleElem.innerText.split('\n')[0] - }); - } - }; - - let normalContextMenuObject = Object.assign({}, baseContextMenuObject); - normalContextMenuObject.selector += normalAssignRClickSelector; - - - let addedContextMenuObject = Object.assign({}, baseContextMenuObject); - addedContextMenuObject.selector += addedAssignRClickSelector; - // replace drop with delete - addedContextMenuObject.items = Object.assign({}, baseContextMenuObject.items); - addedContextMenuObject.items.drop = undroppedAssignItemSet.delete; - - $.contextMenu(normalContextMenuObject); - $.contextMenu(addedContextMenuObject); - } - - $.contextMenu({ - selector: droppedAssignRClickSelector, - items: { - undrop: { - name: "Undrop", - callback: function (key, opt) { - trackEvent("assignment", "undrop", "What-If Grades"); - this[0].classList.remove("dropped"); - // alter grade - let gradeColContentWrap = this[0].querySelector(".grade-wrapper").parentElement; - removeExceptionState(this[0], gradeColContentWrap); - // TODO refactor the grade extraction - let score = gradeColContentWrap.querySelector(".rounded-grade") || gradeColContentWrap.querySelector(".rubric-grade-value"); - let maxGrade = gradeColContentWrap.querySelector(".max-grade"); - let scoreVal = 0; - let maxVal = 0; - - if (score && maxGrade) { - scoreVal = Number.parseFloat(score.textContent); - maxVal = Number.parseFloat(maxGrade.textContent.substring(3)); - } else if (this[0].querySelector(".exception-icon.missing")) { - let scoreValues = maxGrade.textContent.split("/"); - scoreVal = Number.parseFloat(scoreValues[0]); - maxVal = Number.parseFloat(scoreValues[1]); - } - - if (!gradeColContentWrap.querySelector(".modified-score-percent-warning")) { - //gradeColContentWrap.getElementsByClassName("injected-assignment-percent")[0].style.paddingRight = "0"; - gradeColContentWrap.appendChild(generateScoreModifyWarning()); - gradesModified = true; - } - - let catId = this[0].dataset.parentId; - let catRow = Array.prototype.find.call(this[0].parentElement.getElementsByTagName("tr"), e => e.dataset.id == catId); - recalculateCategoryScore(catRow, scoreVal, maxVal); - - let perId = catRow.dataset.parentId; - let perRow = Array.prototype.find.call(this[0].parentElement.getElementsByTagName("tr"), e => e.dataset.id == perId); - recalculatePeriodScore(perRow, scoreVal, maxVal); - } - } - } - }); - - for (let kabob of document.getElementsByClassName("kabob-menu")) { - if (invalidCategories.includes(kabob.dataset.parentId)) continue; - - kabob.classList.remove("hidden"); - } - // uncheck the grades modify box without having modified grades - } else if (!gradesModified) { - for (let edit of document.getElementsByClassName("grade-edit-indicator")) { - edit.style.display = "none"; - } - for (let edit of document.getElementsByClassName("grade-add-indicator")) { - edit.style.display = "none"; - if (edit.previousElementSibling.classList.contains("item-row") && !edit.previousElementSibling.classList.contains("last-row-of-tier")) { - edit.previousElementSibling.classList.add("last-row-of-tier"); - } - } - for (let kabob of document.getElementsByClassName("kabob-menu")) { - kabob.classList.add("hidden"); - } - for (let courseElement of document.getElementsByClassName("gradebook-course")) { - $.contextMenu("destroy", "#" + courseElement.id + " " + normalAssignRClickSelector); - $.contextMenu("destroy", "#" + courseElement.id + " " + addedAssignRClickSelector); - } - for (let arrow of document.getElementsByClassName("injected-empty-category-expand-icon")) { - arrow.style.visibility = "hidden"; - } - $.contextMenu("destroy", droppedAssignRClickSelector); - // uncheck the edit checkbox, with modified grades existing: prompt: does user confirm? - } else if (confirm("Disabling grade edits now will reload the page and erase all existing modified grades. Proceed?")) { - // not going to try to undo any grade modifications - document.location.reload(); - // attempted to disable grade editing but backed out of confirmation prompt - } else { - document.getElementById("enable-modify").checked = true; - } - } - })); - - if (!gradeModifLabelFirst) { - timeRow.appendChild(timeRowLabel); - } - } - - for (let courseTask of courseLoadTasks) { - await courseTask; - } - - if (!document.location.search.includes("past") || document.location.search.split("past=")[1] != 1) { - if (Setting.getValue("orderClasses") == "period") { - for (let course of coursesByPeriod) { - if (course) { - course.parentElement.appendChild(course); - } - } - } - } - - function getLetterGrade(gradingScale, percentage) { - let sorted = Object.keys(gradingScale).sort((a, b) => b - a); - for (let s of sorted) { - if (percentage >= Number.parseInt(s)) { - return gradingScale[s]; - } - } - return "?"; - } - - function queueNonenteredAssignment(assignment, courseId) { - let noGrade = assignment.getElementsByClassName("no-grade")[0]; - - if (!noGrade) { - Logger.log(`Error loading potentially nonentered assignment with ID ${assignment.dataset.id.substr(2)}`); - return; - } - - if (noGrade.parentElement.classList.contains("exception-grade-wrapper")) { - noGrade.remove(); - assignment.classList.add("contains-exception") - // an exception case - // now we just have to be careful to avoid double-counting - noGrade = assignment.querySelector(".exception .exception-icon"); - if (noGrade) { - // the text gets in the way - let exceptionDesc = assignment.querySelector(".exception .exception-text"); - noGrade.title = exceptionDesc.textContent; - exceptionDesc.remove(); - } - } - - if (noGrade && assignment.dataset.id) { - // do this while the other operation is happening so we don't block the page load - // don't block on it - - let maxGrade = document.createElement("span"); - maxGrade.classList.add("max-grade"); - maxGrade.classList.add("no-grade"); - maxGrade.textContent = " / —"; - noGrade.insertAdjacentElement("afterend", maxGrade); - - let f = async () => { - let domAssignId = assignment.dataset.id.substr(2); - Logger.log(`Fetching max points for (nonentered) assignment ${domAssignId}`); - - let response = null; - let firstTryError = null; - - try { - response = await fetchApi(`sections/${courseId}/assignments/${domAssignId}`); - } catch (err) { - firstTryError = err; - } - - if (response && !response.ok) { - firstTryError = { status: response.status, error: response.statusText }; - } else if (response) { - try { - let json = await response.json(); - - if (json && json.max_points !== undefined) { - // success case - // note; even if maxGrade is removed from the DOM, this will still work - maxGrade.textContent = " / " + json.max_points; - maxGrade.classList.remove("no-grade"); - } else { - firstTryError = "JSON returned without max points"; - } - } catch (err) { - firstTryError = err; - } - } else if (!firstTryError) { - firstTryError + "Unknown error fetching API response"; - } - - if (!firstTryError) return; - - Logger.log(`Error directly fetching max points for (nonentered) assignment ${domAssignId}, reverting to list-search`); - - try { - response = await fetchApi(`users/${getUserId()}/grades?section_id=${courseId}`); - if (!response.ok) { - throw { status: response.status, error: response.statusText }; - } - let json = await response.json(); - - if (json && json.section.length > 0) { - // success case - // note; even if maxGrade is removed from the DOM, this will still work - maxGrade.textContent = " / " + json.section[0].period[0].assignment.filter(x => x.assignment_id == Number.parseInt(domAssignId))[0].max_points; - maxGrade.classList.remove("no-grade"); - } else { - throw "List search failed to obtain meaningful response"; - } - } catch (err) { - throw { listSearchErr: err, firstTryError: firstTryError }; - } - }; - fetchQueue.push([f, 0]); - } - - // td-content-wrapper - noGrade.parentElement.appendChild(document.createElement("br")); - let injectedPercent = createElement("span", ["percentage-grade", "injected-assignment-percent"], { textContent: "N/A" }); - noGrade.parentElement.appendChild(injectedPercent); - } - - function prepareScoredAssignmentGrade(spanPercent, score, max) { - spanPercent.textContent = max === 0 ? "EC" : `${Math.round(score * 100 / max)}%`; - spanPercent.title = max === 0 ? "Extra Credit" : `${score * 100 / max}%`; - if (!spanPercent.classList.contains("max-grade")) { - spanPercent.classList.add("max-grade"); - } - if (!spanPercent.classList.contains("injected-assignment-percent")) { - spanPercent.classList.add("injected-assignment-percent"); - } - } - - function setGradeText(gradeElement, sum, max, row, doNotDisplay) { - if (gradeElement) { - let courseId = row.parentElement.firstElementChild.dataset.id; - // currently there exists a letter grade here, we want to put a point score here and move the letter grade - let text = gradeElement.parentElement.textContent; - let textContent = gradeElement.parentElement.textContent; - gradeElement.parentElement.classList.add("grade-column-center"); - //gradeElement.parentElement.style.textAlign = "center"; - //gradeElement.parentElement.style.paddingRight = "30px"; - gradeElement.innerHTML = ""; - // create the elements for our point score - gradeElement.appendChild(createElement("span", ["rounded-grade"], { textContent: doNotDisplay ? "" : Math.round(sum * 100) / 100 })); - gradeElement.appendChild(createElement("span", ["max-grade"], { textContent: doNotDisplay ? "" : ` / ${Math.round(max * 100) / 100}` })); - // move the letter grade over to the right - span = row.querySelector(".comment-column").firstChild; - span.textContent = text; - - addLetterGrade(span, courseId); - - // restyle the right hand side - span.parentElement.classList.remove("comment-column"); - span.parentElement.classList.add("grade-column"); - span.parentElement.classList.add("grade-column-right"); - //span.style.cssFloat = "right"; //maybe remove - //span.style.color = "#3aa406"; - //span.style.fontWeight = "bold"; - } - } - - function addLetterGrade(elem, courseId) { - let gradingScale = Setting.getValue("getGradingScale")(courseId); - if (Setting.getValue("customScales") != "disabled" && elem.textContent.match(/^\d+\.?\d*%/) !== null) { - let percent = Number.parseFloat(elem.textContent.substr(0, elem.textContent.length - 1)); - let letterGrade = getLetterGrade(gradingScale, percent); - elem.textContent = `${letterGrade} (${percent}%)`; - elem.title = `Letter grade calculated by Schoology Plus using the following grading scale:\n${Object.keys(gradingScale).sort((a, b) => a - b).reverse().map(x => `${gradingScale[x]}: ${x}%`).join('\n')}\nTo change this grading scale, find 'Course Options' on the page for this course`; - } - } - - function generateScoreModifyWarning() { - return createElement("img", ["modified-score-percent-warning"], { - src: chrome.runtime.getURL("imgs/exclamation-mark.svg"), - title: "This grade has been modified from its true value." - }); - } - - function recalculateCategoryScore(catRow, deltaPoints, deltaMax, warn = true) { - // category always has a numeric score, unlike period - // awarded grade in our constructed element contains both rounded and max - let awardedCategoryPoints = catRow.querySelector(".rounded-grade").parentNode; - let catScoreElem = awardedCategoryPoints.querySelector(".rounded-grade"); - let catMaxElem = awardedCategoryPoints.querySelector(".max-grade"); - let newCatScore = Number.parseFloat(catScoreElem.textContent) + deltaPoints; - let newCatMax = Number.parseFloat(catMaxElem.textContent.substring(3)) + deltaMax; - catScoreElem.textContent = newCatScore; - catMaxElem.textContent = " / " + newCatMax; - if (warn && !awardedCategoryPoints.querySelector(".modified-score-percent-warning")) { - awardedCategoryPoints.appendChild(generateScoreModifyWarning()); - } - // category percentage - // need to recalculate - // content wrapper in right grade col - let awardedCategoryPercentContainer = catRow.querySelector(".grade-column-right").firstElementChild; - let awardedCategoryPercent = awardedCategoryPercentContainer; - // clear existing percentage indicator - while (awardedCategoryPercent.firstChild) { - awardedCategoryPercent.firstChild.remove(); - } - awardedCategoryPercent.appendChild(document.createElement("span")); - awardedCategoryPercent = awardedCategoryPercent.firstElementChild; - awardedCategoryPercent.classList.add("awarded-grade"); - awardedCategoryPercent.appendChild(document.createElement("span")); - awardedCategoryPercent = awardedCategoryPercent.firstElementChild; - awardedCategoryPercent.classList.add("numeric-grade"); - awardedCategoryPercent.classList.add("primary-grade"); - awardedCategoryPercent.appendChild(document.createElement("span")); - awardedCategoryPercent = awardedCategoryPercent.firstElementChild; - awardedCategoryPercent.classList.add("rounded-grade"); - - let newCatPercent = (newCatScore / newCatMax) * 100; - awardedCategoryPercent.title = newCatPercent + "%"; - awardedCategoryPercent.textContent = (Math.round(newCatPercent * 100) / 100) + "%"; - - if (warn && !awardedCategoryPercentContainer.querySelector(".modified-score-percent-warning")) { - awardedCategoryPercentContainer.prepend(generateScoreModifyWarning()); - } - } - - function recalculatePeriodScore(perRow, deltaPoints, deltaMax, warn = true) { - let awardedPeriodPercentContainer = perRow.querySelector(".grade-column-right").firstElementChild; - let awardedPeriodPercent = awardedPeriodPercentContainer; - // clear existing percentage indicator - while (awardedPeriodPercent.firstChild) { - awardedPeriodPercent.firstChild.remove(); - } - awardedPeriodPercent.appendChild(document.createElement("span")); - awardedPeriodPercent = awardedPeriodPercent.firstElementChild; - awardedPeriodPercent.classList.add("awarded-grade"); - awardedPeriodPercent.appendChild(document.createElement("span")); - awardedPeriodPercent = awardedPeriodPercent.firstElementChild; - awardedPeriodPercent.classList.add("numeric-grade"); - awardedPeriodPercent.classList.add("primary-grade"); - awardedPeriodPercent.appendChild(document.createElement("span")); - awardedPeriodPercent = awardedPeriodPercent.firstElementChild; - awardedPeriodPercent.classList.add("rounded-grade"); - - // now period (semester) - // might have a numeric score (weighting => no numeric, meaning we can assume unweighted if present) - let awardedPeriodPoints = perRow.querySelector(".grade-column-center"); - if (awardedPeriodPoints && awardedPeriodPoints.textContent.trim().length !== 0) { - // awarded grade in our constructed element contains both rounded and max - let perScoreElem = awardedPeriodPoints.querySelector(".rounded-grade"); - let perMaxElem = awardedPeriodPoints.querySelector(".max-grade"); - let newPerScore = Number.parseFloat(perScoreElem.textContent) + deltaPoints; - let newPerMax = Number.parseFloat(perMaxElem.textContent.substring(3)) + deltaMax; - perScoreElem.textContent = newPerScore; - perMaxElem.textContent = " / " + newPerMax; - if (warn && !awardedPeriodPoints.querySelector(".modified-score-percent-warning")) { - awardedPeriodPoints.appendChild(generateScoreModifyWarning()); - } - - // go ahead and calculate period percentage here since we know it's unweighted - let newPerPercent = (newPerScore / newPerMax) * 100; - awardedPeriodPercent.title = newPerPercent + "%"; - awardedPeriodPercent.textContent = (Math.round(newPerPercent * 100) / 100) + "%"; - } else { - let total = 0; - let totalPercentWeight = 0; - for (let category of perRow.parentElement.querySelectorAll(`.category-row[data-parent-id="${perRow.dataset.id}"]`)) { - let weightPercentElement = category.getElementsByClassName("percentage-contrib")[0]; - if (!weightPercentElement) { - continue; - } - let weightPercent = weightPercentElement.textContent; - let col = category.getElementsByClassName("grade-column-right")[0]; - let colMatch = col ? col.textContent.match(/(\d+\.?\d*)%/) : null; - if (colMatch) { - let scorePercent = Number.parseFloat(colMatch[1]); - if (!Number.isNaN(scorePercent)) { - total += (weightPercent.slice(1, -2) / 100) * scorePercent; - totalPercentWeight += Number.parseFloat(weightPercent.slice(1, -2)); - } - } - } - - totalPercentWeight /= 100; - - // if only some categories have assignments, adjust the total accordingly - // if weights are more than 100, this assumes that it's correct as intended (e.c.), I won't mess with it - if (totalPercentWeight > 0 && totalPercentWeight < 1) { - // some categories are specified, but weights don't quite add to 100 - // scale up known grades - total /= totalPercentWeight; - // epsilon because floating point - } else if (totalPercentWeight < 0.00001) { - total = 100; - } - - awardedPeriodPercent.title = total + "%"; - awardedPeriodPercent.textContent = (Math.round(total * 100) / 100) + "%"; - } - - if (warn && !awardedPeriodPercentContainer.querySelector(".modified-score-percent-warning")) { - awardedPeriodPercentContainer.prepend(generateScoreModifyWarning()); - } - } - - function parseAssignmentNumerator(numString, denomFloat, courseId) { - if (Number.isNaN(denomFloat)) { - return Number.NaN; - } - - let numFloat; - let percentMatch = /^(-?[0-9]+(\.[0-9]+)?)%$/.exec(numString); - - if (Number.isFinite(denomFloat) && percentMatch && percentMatch[1]) { - numFloat = denomFloat * Number.parseFloat(percentMatch[1]) / 100; - - if (!Number.isNaN(numFloat)) { - return numFloat; - } - } - - numFloat = Number.parseFloat(numString); - - if (!Number.isNaN(numFloat)) { - return numFloat; - } - - - if (Number.isFinite(denomFloat) && courseId) { - let gradingScale = Setting.getValue("getGradingScale")(courseId); - for (let gradeScalePercent in gradingScale) { - let letterSymbol = gradingScale[gradeScalePercent]; - if (numString == letterSymbol) { - numFloat = (gradeScalePercent / 100) * denomFloat; - break; - } - } - } - - return Number.isFinite(numFloat) ? numFloat : Number.NaN; - } - - function removeExceptionState(assignment, gradeColContentWrap, exceptionIcon, score, maxGrade) { - if (!gradeColContentWrap) { - gradeColContentWrap = assignment.querySelector(".grade-column .td-content-wrapper"); - } - - let gradeWrapper = gradeColContentWrap.querySelector(".grade-wrapper"); - - if (!exceptionIcon) { - exceptionIcon = gradeColContentWrap.querySelector(".exception-icon"); - if (!exceptionIcon) { - return {}; - } - } - - if (!score) { - score = gradeColContentWrap.querySelector(".rounded-grade") || gradeColContentWrap.querySelector(".rubric-grade-value"); - } - - if (!maxGrade) { - maxGrade = gradeColContentWrap.querySelector(".max-grade"); - } - - let retVars = {}; - - // the only exception which counts against the user is "missing" - let missing = exceptionIcon.classList.contains("missing"); - let scoreElem = createElement("span", [missing ? "rounded-grade" : "no-grade"], { textContent: missing ? "0" : "—" }); - retVars.editElem = scoreElem; - retVars.initPts = 0; - if (missing) { - retVars.score = scoreElem; - scoreElem = createElement("span", ["awarded-grade"], {}, [retVars.score, maxGrade]); - retVars.initMax = Number.parseFloat(maxGrade.textContent.substring(3)); - } else { - retVars.initMax = 0; - retVars.noGrade = scoreElem; - } - // reorganize - let elemToRemove = exceptionIcon.parentElement.parentElement; - let nodesToMoveHolder = exceptionIcon.parentElement; - - exceptionIcon.insertAdjacentElement('afterend', scoreElem); - exceptionIcon.remove(); - - let nodesToMove = Array.from(nodesToMoveHolder.childNodes); - let nodesAfterMove = nodesToMove.splice(nodesToMove.findIndex(x => x.tagName == "BR")); - nodesToMove.reverse(); - nodesAfterMove.reverse(); - for (let i = 0; i < nodesToMove.length; i++) { - gradeColContentWrap.insertAdjacentElement('afterbegin', nodesToMove[i]); - } - for (let i = 0; i < nodesAfterMove.length; i++) { - gradeWrapper.insertAdjacentElement('afterend', nodesAfterMove[i]); - } - elemToRemove.remove(); - - assignment.classList.remove("contains-exception"); - } - - function createEditListener(assignment, gradeColContentWrap, catRow, perRow, finishedCallback) { - return function () { - trackEvent("assignment", "change-grade", "What-If Grades"); - removeExceptionState(assignment, gradeColContentWrap); - - let noGrade = gradeColContentWrap.querySelector(".no-grade"); - let score = gradeColContentWrap.querySelector(".rounded-grade") || gradeColContentWrap.querySelector(".rubric-grade-value"); - // note that this will always return (for our injected percentage element) - let maxGrade = gradeColContentWrap.querySelector(".max-grade"); - let editElem; - let initPts; - let initMax; - if (noGrade) { - editElem = noGrade; - initPts = 0; - initMax = 0; - if (maxGrade && maxGrade.classList.contains("no-grade")) { - maxGrade.remove(); - maxGrade = null; - } - } - if (score && maxGrade) { - editElem = score; - initPts = Number.parseFloat(score.textContent); - initMax = Number.parseFloat(maxGrade.textContent.substring(3)); - } - - if (!editElem || editElem.classList.contains("student-editable")) { - return; - } - - editElem.classList.add("student-editable"); - editElem.contentEditable = true; - - // TODO refactor - // (this) tr -> tbody -> table -> div.gradebook-course-grades -> relevant div - let courseId = Number.parseInt(/course-(\d+)$/.exec(perRow.parentElement.parentElement.parentElement.parentElement.id)[1]); - - // TODO blur v focusout - let submitFunc = function () { - if (!editElem.classList.contains("student-editable")) { - // we've already processed this event, ignore and return for cleanup - return true; - } - - let userScore; - let userMax; - if (noGrade) { - // regex capture and check - if (maxGrade) { - // noGrade+maxGrade = inserted maxGrade from an API call - // initDenom = 0; newDenom = whatever is in that textfield - userMax = Number.parseFloat(maxGrade.textContent.substring(3)); - userScore = parseAssignmentNumerator(noGrade.textContent, userMax, courseId); - } else { - let regexResult = /^(-?\d+(\.\d+)?)\s*\/\s*(-?\d+(\.\d+)?)$/.exec(editElem.textContent); - if (!regexResult) { - return false; - } - userMax = Number.parseFloat(regexResult[3]); - userScore = parseAssignmentNumerator(regexResult[1], userMax, courseId); - } - if (Number.isNaN(userScore) || Number.isNaN(userMax)) { - return false; - } - } else if (score) { - // user entered number must be a numeric - userScore = parseAssignmentNumerator(score.textContent, initMax, courseId); - userMax = initMax; - if (Number.isNaN(userScore)) { - return false; - } - } else { - // ??? - Logger.warn("unexpected case of field type in editing grade"); - return false; - } - - // we've established a known new score and max, with an init score and max to compare to - let deltaPoints = userScore - initPts; - let deltaMax = userMax - initMax; - // first, replace no grades - if (noGrade) { - if (!maxGrade) { - maxGrade = createElement("span", ["max-grade"], { textContent: " / " + userMax }); - gradeColContentWrap.prepend(maxGrade); - } - let awardedGrade = createElement("span", ["awarded-grade"]); - score = createElement("span", ["rounded-grade"], { title: userScore, textContent: userScore }); - awardedGrade.appendChild(score); - gradeColContentWrap.prepend(score); - noGrade.remove(); - } else { - // we already have our DOM elements - score.title = userScore; - score.textContent = userScore; - // will not have changed but still - maxGrade.textContent = " / " + userMax; - score.contentEditable = false; - score.classList.remove("student-editable"); - - // if there's a letter grade, remove it - it might be inaccurate - if (score.parentElement && score.parentElement.parentElement && score.parentElement.parentElement.tagName.toUpperCase() === "SPAN" && score.parentElement.parentElement.classList.contains("awarded-grade") && /^[A-DF] /.test(score.parentElement.parentElement.textContent)) { - // note use of childNodes, it's not its own element - score.parentElement.parentElement.childNodes[0].remove(); - } - } - // update the assignment percentage - prepareScoredAssignmentGrade(gradeColContentWrap.querySelector(".injected-assignment-percent"), userScore, userMax); - if (!gradeColContentWrap.querySelector(".modified-score-percent-warning")) { - //gradeColContentWrap.getElementsByClassName("injected-assignment-percent")[0].style.paddingRight = "0"; - gradeColContentWrap.appendChild(generateScoreModifyWarning()); - gradesModified = true; - } - - // don't alter totals for dropped assignment - if (assignment.classList.contains("dropped")) { - if (finishedCallback) { - finishedCallback(); - } - - return true; - } - - recalculateCategoryScore(catRow, deltaPoints, deltaMax); - recalculatePeriodScore(perRow, deltaPoints, deltaMax); - - if (finishedCallback) { - finishedCallback(); - } - - return true; - }; - let cleanupFunc = function () { - editElem.removeEventListener("blur", blurFunc); - editElem.removeEventListener("keydown", keyFunc); - }; - let keyFunc = function (event) { - if (event.which == 13 || event.keyCode == 13) { - editElem.blur(); - return false; - } - return true; - }; - let blurFunc = function (event) { - if (submitFunc()) { - cleanupFunc(); - var sel = window.getSelection ? window.getSelection() : document.selection; - if (sel) { - if (sel.removeAllRanges) { - sel.removeAllRanges(); - } else if (sel.empty) { - sel.empty(); - } - } - } else { - editElem.focus(); - } - return false; - } - editElem.addEventListener("blur", blurFunc); - editElem.addEventListener("keydown", keyFunc); - editElem.focus(); - document.execCommand('selectAll', false, null); - }; - } -})().then(() => { - Logger.log("Retrieving (" + fetchQueue.length + ") nonentered assignments info...") - processNonenteredAssignments(); -}).catch(reason => { - Logger.error("Error running grades page modification script: ", reason); -}); - -function createAddAssignmentElement(category) { - let addAssignmentThing = createElement("tr", ["report-row", "item-row", "last-row-of-tier", "grade-add-indicator"]); - addAssignmentThing.dataset.parentId = category.dataset.id; - // to avoid a hugely annoying DOM construction - // edit indicator will be added later - // FIXME add little plus icon - addAssignmentThing.innerHTML = '
No comment
'; - addAssignmentThing.getElementsByClassName("title")[0].firstElementChild.addEventListener("click", function () { - if (event.target.contentEditable !== "true") { - addAssignmentThing.querySelector("img.grade-edit-indicator").click(); - } - else { - document.execCommand("selectall", null, false); - } - }); - return addAssignmentThing; -} - -function processNonenteredAssignments() { - if (fetchQueue.length > 0) { - let [func, attempts] = fetchQueue.shift() - sleep = attempts > 0 - setTimeout(() => { - func().then(x => { - processNonenteredAssignments(); - }).catch(err => { - Logger.warn("Caught error: ", err); - Logger.log("Waiting 3 seconds to avoid rate limit"); - if (err && err.firstTryError && err.firstTryError.status === 403) { - attempts = 100; - } - if (attempts > 3) { - Logger.warn("Maximum attempts reached; aborting"); - } else { - fetchQueue.push([func, attempts + 1]) - } - processNonenteredAssignments(); - }); - }, sleep ? 3000 : 0); - } -} - +(async function () { + // Wait for loader.js to finish running + while (!window.splusLoaded) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + await loadDependencies("grades", ["all"]); +})(); + +const timeout = ms => new Promise(res => setTimeout(res, ms)); +const BUG_REPORT_FORM_LINK = "https://docs.google.com/forms/d/e/1FAIpQLScF1_MZofOWT9pkWp3EfKSvzCPpyevYtqbAucp1K5WKGlckiA/viewform?entry.118199430="; +const SINGLE_COURSE = window.location.href.includes("/course/"); +var editDisableReason = null; +var invalidCategories = []; + +function addEditDisableReason(err = "Unknown Error", causedBy403 = false, causedByNoApiKey = false) { + if (!editDisableReason) { + editDisableReason = { version: chrome.runtime.getManifest().version, errors: [], allCausedBy403: causedBy403, causedByNoApiKey: causedByNoApiKey }; + } + editDisableReason.errors.push(err); + editDisableReason.allCausedBy403 = editDisableReason.allCausedBy403 && causedBy403; + editDisableReason.causedByNoApiKey = editDisableReason.causedByNoApiKey || causedByNoApiKey; + Logger.debug(editDisableReason, err, causedBy403, causedByNoApiKey); +} + +$.contextMenu({ + selector: ".gradebook-course-title", + items: { + options: { + name: "Course Options", + callback: function (key, opt) { + trackEvent("context_menu_click", { + id: "Course Options", + context: "Grades Page", + legacyTarget: "Course Options", + legacyAction: "click", + legacyLabel: "Grades Context Menu" + }); + openModal("course-settings-modal", { + courseId: this[0].parentElement.id.match(/\d+/)[0], + courseName: this[0].querySelector("a span:nth-child(3)") ? this[0].querySelector("a span:nth-child(2)").textContent : this[0].innerText.split('\n')[0] + }); + } + }, + grades: { + name: "Change Grading Scale", + callback: function (key, opt) { + trackEvent("context_menu_click", { + id: "Change Grading Scale", + context: "Grades Page", + legacyTarget: "Change Grading Scale", + legacyAction: "click", + legacyLabel: "Grades Context Menu" + }); + openModal("course-settings-modal", { + courseId: this[0].parentElement.id.match(/\d+/)[0], + courseName: this[0].querySelector("a span:nth-child(3)") ? this[0].querySelector("a span:nth-child(2)").textContent : this[0].innerText.split('\n')[0] + }); + } + }, + separator: "-----", + materials: { + name: "Materials", + callback: function (key, opt) { + trackEvent("context_menu_click", { + id: "Materials", + context: "Grades Page", + legacyTarget: "Materials", + legacyAction: "click", + legacyLabel: "Grades Context Menu" + }); + window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].parentElement.id.match(/\d+/)[0]}/materials`, "_blank") + } + }, + updates: { + name: "Updates", + callback: function (key, opt) { + trackEvent("context_menu_click", { + id: "Updates", + context: "Grades Page", + legacyTarget: "Updates", + legacyAction: "click", + legacyLabel: "Grades Context Menu" + }); + window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].parentElement.id.match(/\d+/)[0]}/updates`, "_blank") + } + }, + student_grades: { + name: "Grades", + callback: function (key, opt) { + trackEvent("context_menu_click", { + id: "Grades", + context: "Grades Page", + legacyTarget: "Grades", + legacyAction: "click", + legacyLabel: "Grades Context Menu" + }); + window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].parentElement.id.match(/\d+/)[0]}/student_grades`, "_blank") + } + }, + mastery: { + name: "Mastery", + callback: function (key, opt) { + trackEvent("context_menu_click", { + id: "Mastery", + context: "Grades Page", + legacyTarget: "Mastery", + legacyAction: "click", + legacyLabel: "Grades Context Menu" + }); + window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].parentElement.id.match(/\d+/)[0]}/mastery`, "_blank") + } + }, + members: { + name: "Members", + callback: function (key, opt) { + trackEvent("context_menu_click", { + id: "Members", + context: "Grades Page", + legacyTarget: "Members", + legacyAction: "click", + legacyLabel: "Grades Context Menu" + }); + window.open(`https://${Setting.getValue("defaultDomain")}/course/${this[0].parentElement.id.match(/\d+/)[0]}/members`, "_blank") + } + } + } +}); + +var fetchQueue = []; +(async function () { + Logger.log("Running Schoology Plus grades page improvement script"); + + let inner = document.getElementById("main-inner") || document.getElementById("content-wrapper"); + let courses = inner.getElementsByClassName("gradebook-course"); + let coursesByPeriod = []; + let gradesModified = false; + + let upperPeriodSortBound = 20; + + let courseLoadTasks = []; + + for (let course of courses) { + courseLoadTasks.push((async function () { + let title = course.querySelector(".gradebook-course-title"); + let summary = course.querySelector(".summary-course"); + let courseId = title.parentElement.id.match(/\d+/)[0]; + let courseGrade; + if (summary) { + courseGrade = summary.querySelector(".awarded-grade"); + } else { + try { + let finalGradeArray = (await fetchApiJson(`users/${getUserId()}/grades?section_id=${courseId}`)).section[0].final_grade; + courseGrade = createElement("span", [], { textContent: `${finalGradeArray[finalGradeArray.length - 1].grade.toString()}%` }); + } catch { + courseGrade = null; + } + } + let table = course.querySelector(".gradebook-course-grades").firstElementChild; + let grades = table.firstElementChild; + let categories = Array.from(grades.getElementsByClassName("category-row")); + let rows = Array.from(grades.children); + let periods = Array.from(course.getElementsByClassName("period-row")).filter(x => !x.textContent.includes("(no grading period)")); + + let classPoints = 0; + let classTotal = 0; + let addMoreClassTotal = true; + + // if there's no PERIOD \d string in the course name, match will return null; in that case, use the array [null, i++] + // OR is lazy, so the ++ won't trigger unnecessarily; upperPeriodSortBound is our array key, and we use it to give a unique index (after all course) to periodless courses + coursesByPeriod[Number.parseInt((title.textContent.match(/\b[Pp][Ee]?[Rr]?[Ii]?[Oo]?[Dd]?\s*(\d+)/) || [null, upperPeriodSortBound++])[1])] = course; + + // Fix width of assignment columns + table.appendChild(createElement("colgroup", [], {}, [ + createElement("col", ["assignment-column"]), + createElement("col", ["points-column"]), + createElement("col", ["comments-column"]) + ])); + + let kabobMenuButton = createElement("span", ["grades-kabob-menu"], { + textContent: "⠇", + onclick: function (event) { + $(title).contextMenu({ x: event.pageX, y: event.pageY }); + // hacky way to prevent the course from expanding + title.click(); + } + }); + let grade = createElement("span", ["awarded-grade", "injected-title-grade", courseGrade ? "grade-active-color" : "grade-none-color"], { textContent: "LOADING" }); + title.appendChild(kabobMenuButton); + title.appendChild(grade); + + for (let period of periods) { + let periodPoints = 0; + let periodTotal = 0; + let invalidatePerTotal = false; + + for (let category of categories.filter(x => period.dataset.id == x.dataset.parentId)) { + try { + let assignments = rows.filter(x => category.dataset.id == x.dataset.parentId); + let sum = 0; + let max = 0; + let processAssignment = async function (assignment) { + let maxGrade = assignment.querySelector(".max-grade"); + let score = assignment.querySelector(".rounded-grade") || assignment.querySelector(".rubric-grade-value"); + if (score) { + let assignmentScore = Number.parseFloat(score.textContent); + let assignmentMax = Number.parseFloat(maxGrade.textContent.substring(3)); + + if (!assignment.classList.contains("dropped")) { + sum += assignmentScore; + max += assignmentMax; + } + + let newGrade = document.createElement("span"); + prepareScoredAssignmentGrade(newGrade, assignmentScore, assignmentMax); + + // td-content-wrapper + maxGrade.parentElement.appendChild(document.createElement("br")); + maxGrade.parentElement.appendChild(newGrade); + } + else { + queueNonenteredAssignment(assignment, courseId, period, category); + } + if (assignment.querySelector(".missing")) { + // get denominator for missing assignment + let p = assignment.querySelector(".injected-assignment-percent"); + p.textContent = "0%"; + p.title = "Assignment missing"; + Logger.log(`Fetching max points for assignment ${assignment.dataset.id.substr(2)}`); + + let json = await fetchApiJson(`users/${getUserId()}/grades?section_id=${courseId}`); + + if (json.section.length === 0) { + throw new Error("Assignment details could not be read"); + } + + const assignments = json.section[0].period.reduce((prevVal, curVal) => prevVal.concat(curVal.assignment), []);//combines the assignment arrays from each period + let pts = Number.parseFloat(assignments.filter(x => x.assignment_id == assignment.dataset.id.substr(2))[0].max_points); + if (!assignment.classList.contains("dropped")) { + max += pts; + Logger.log(`Max points for assignment ${assignment.dataset.id.substr(2)} is ${pts}`); + } + } + //assignment.style.padding = "7px 30px 5px"; + //assignment.style.textAlign = "center"; + + // kabob menu + let commentsContentWrapper = assignment.querySelector(".comment-column").firstElementChild; + let kabobMenuButton = createElement("span", ["kabob-menu"], { + textContent: "⠇", + onclick: function (event) { + $(assignment).contextMenu({ x: event.pageX, y: event.pageY }); + } + }); + kabobMenuButton.dataset.parentId = assignment.dataset.parentId; + + let editEnableCheckbox = document.getElementById("enable-modify"); + + // not created yet and thus editing disabled, or created the toggle but editing disabled + if (!editEnableCheckbox || !editEnableCheckbox.checked) { + kabobMenuButton.classList.add("hidden"); + } + + commentsContentWrapper.insertAdjacentElement("beforeend", kabobMenuButton); + if (commentsContentWrapper.querySelector(".comment")) { + // Fixes kabob display issues with long comments + commentsContentWrapper.style.display = "flex"; + // Fixes kabob display issues with short comments + commentsContentWrapper.style.justifyContent = "space-between"; + } + + let createAddAssignmentUi = async function () { + let addAssignmentThing = createAddAssignmentElement(category); + + if (assignment.classList.contains("hidden")) { + addAssignmentThing.classList.add("hidden"); + } + + assignment.insertAdjacentElement('afterend', addAssignmentThing); + await processAssignment(addAssignmentThing); + + return addAssignmentThing; + }; + + // add UI for grade virtual editing + let gradeWrapper = assignment.querySelector(".grade-wrapper"); + + let checkbox = document.getElementById("enable-modify"); + let gradeAddEditHandler = null; + let editGradeImg = createElement("img", ["grade-edit-indicator"], { + src: chrome.runtime.getURL("imgs/edit-pencil.svg"), + width: 12, + style: `display: ${checkbox && checkbox.checked ? "unset" : "none"};` + }); + editGradeImg.dataset.parentId = assignment.dataset.parentId; + if (assignment.classList.contains("grade-add-indicator")) { + // when this is clicked, if the edit was successful, we don't have to worry about making our changes reversible cleanly + // the reversal takes the form of a page refresh once grades have been changed + let hasHandledGradeEdit = false; + gradeAddEditHandler = async function () { + if (hasHandledGradeEdit) { + return; + } + + assignment.classList.remove("grade-add-indicator"); + assignment.classList.remove("last-row-of-tier"); + + assignment.classList.add("added-fake-assignment"); + trackEvent("button_click", { + id: "create-fake-assignment", + context: "What-If Grades", + legacyTarget: "assignment", + legacyAction: "create-fake", + legacyLabel: "What-If Grades" + }); + + let assignmentTitle = assignment.getElementsByClassName("title")[0].firstElementChild; + assignmentTitle.textContent = "Added Assignment (Click to Rename)"; + assignmentTitle.classList.add("editable-assignment-name"); + assignmentTitle.contentEditable = "true"; + assignmentTitle.addEventListener("keydown", event => { + if (event.which === 13) { + event.target.blur(); + window.getSelection().removeAllRanges(); + } + }); + + let newAddAssignmentPlaceholder = await createAddAssignmentUi(); + newAddAssignmentPlaceholder.style.display = "table-row"; + + hasHandledGradeEdit = true; + }; + } + // edit image + editGradeImg.addEventListener("click", createEditListener(assignment, gradeWrapper.parentElement, category, period, gradeAddEditHandler)); + gradeWrapper.appendChild(editGradeImg); + // edit text + const gradeText = assignment.querySelector(".awarded-grade") || assignment.querySelector(".no-grade"); + gradeText.addEventListener("click", createEditListener(assignment, gradeWrapper.parentElement, category, period, gradeAddEditHandler)); + + if (assignment.classList.contains("last-row-of-tier") && !assignment.classList.contains("grade-add-indicator")) { + await createAddAssignmentUi(); + } + }; + + let invalidateCatTotal = false; + + for (let assignment of assignments) { + try { + await processAssignment(assignment); + } catch (err) { + if (err === "noapikey") { + addEditDisableReason({ error: { message: err, name: err, stack: undefined, full: JSON.stringify(err) }, courseId, course: title.textContent, assignment: assignment.textContent }, false, true); + } else { + if (!assignment.classList.contains("dropped") && assignment.querySelector(".missing")) { + // consequential failure: our denominator is invalid + invalidateCatTotal = true; + invalidCategories.push(category.dataset.id); + + if ("status" in err && err.status === 403) { + addEditDisableReason({ + error: { + message: err.error, + status: err.status + }, + courseId, + course: title.textContent, + assignment: assignment.textContent + }, true, err === "noapikey"); + continue; + } + } + + addEditDisableReason({ error: { message: err.message, name: err.name, stack: err.stack, full: JSON.stringify(err) }, courseId, course: title.textContent, assignment: assignment.textContent }, false, err === "noapikey"); + } + Logger.error("Error loading assignment for " + courseId + ": ", assignment, err); + } + } + + if (assignments.length === 0) { + category.querySelector(".grade-column").classList.add("grade-column-center"); + + let createAddAssignmentUi = async function () { + let addAssignmentThing = createAddAssignmentElement(category); + + category.insertAdjacentElement('afterend', addAssignmentThing); + await processAssignment(addAssignmentThing); + + return addAssignmentThing; + }; + + let categoryArrow = createElement("img", ["expandable-icon-grading-report", "injected-empty-category-expand-icon"], { src: "/sites/all/themes/schoology_theme/images/expandable-sprite.png" }); + category.querySelector("th .td-content-wrapper").prepend(categoryArrow); + + if (!category.classList.contains("hidden")) { + await createAddAssignmentUi(); + } + } + + let gradeText = category.querySelector(".awarded-grade") || category.querySelector(".no-grade"); + if (!gradeText) { + gradeText = createElement("span", ["awarded-grade"], { textContent: "—" }); + category.querySelector(".grade-column .td-content-wrapper").appendChild(gradeText); + setGradeText(gradeText, sum, max, category); + recalculateCategoryScore(category, 0, 0, false, courseId); + } else { + setGradeText(gradeText, sum, max, category); + } + + gradeText.classList.remove("no-grade"); + gradeText.classList.add("awarded-grade"); + + if (invalidateCatTotal) { + let catMaxElem = gradeText.parentElement.querySelector(".grade-column-center .max-grade"); + catMaxElem.classList.add("max-grade-show-error"); + invalidatePerTotal = true; + } + + let weightText = category.querySelector(".percentage-contrib"); + if (addMoreClassTotal) { + if (!weightText) { + classPoints += sum; + classTotal += max; + periodPoints += sum; + periodTotal += max; + } else if (weightText.textContent == "(100%)") { + classPoints = sum; + classTotal = max; + periodPoints = sum; + periodTotal = max; + addMoreClassTotal = false; + } else { + // there are weighted categories that aren't 100%, abandon our calculation + classPoints = 0; + classTotal = 0; + periodPoints = 0; + periodTotal = 0; + addMoreClassTotal = false; + } + } + } catch (err) { + addEditDisableReason({ error: JSON.stringify(err, Object.getOwnPropertyNames(err)), category: category.textContent }, false, err === "noapikey") + } + } + + + let gradeText = period.querySelector(".awarded-grade") || period.querySelector(".no-grade"); + if (!gradeText) { + gradeText = createElement("span", ["awarded-grade"], { textContent: "—" }); + period.querySelector(".grade-column .td-content-wrapper").appendChild(gradeText); + setGradeText(gradeText, periodPoints, periodTotal, period, periodTotal === 0); + recalculatePeriodScore(period, 0, 0, false, courseId); + } else { + setGradeText(gradeText, periodPoints, periodTotal, period, periodTotal === 0); + } + + // add weighted indicator to the course section row + if (classTotal === 0 && !addMoreClassTotal) { + let newElem = createElement("span", ["splus-weighted-gradebook-indicator"], { + textContent: "[Weighted]" + }); + let periodPercent = period.querySelector("span.percentage-contrib"); + if (periodPercent) { + periodPercent.insertAdjacentElement('beforebegin', newElem); + } + } + + if (invalidatePerTotal && classTotal !== 0) { + let perMaxElem = gradeText.parentElement.querySelector(".grade-column-center .max-grade"); + perMaxElem.classList.add("max-grade-show-error"); + } + } + + grade.textContent = courseGrade ? courseGrade.textContent : "—"; + addLetterGrade(grade, courseId); + + // FIXME this is duplicated logic to get the div.gradebook-course given the period row + let gradebookCourseContainerDiv = periods[0]; + + + // FIXME null coalesence hack + // .parentElement.parentElement.parentElement.parentElement + (function () { + for (let i = 0; i < 4; i++) { + if (gradebookCourseContainerDiv != null) { + gradebookCourseContainerDiv = gradebookCourseContainerDiv.parentElement; + } + } + })(); + + // points needed for (next grade) / point buffer from (next lowest grade) logic + if (gradebookCourseContainerDiv != null && classTotal != 0 && periods.length === 1) { + let summaryPointsContainer = gradebookCourseContainerDiv.querySelector(".gradebook-course-grades .summary-course"); + + let gradeScale = Setting.getValue("getGradingScale")(courseId); + let myPercentage = (classPoints / classTotal) * 100; + + if (summaryPointsContainer) { + let gradeScaleSorted = Object.keys(gradeScale).sort((a, b) => b - a).map(function (p) { return { symbol: gradeScale[p], minGrade: +p }; }); + + let myGradeIndex = -1; + + for (let i = 0; i < gradeScaleSorted.length; i++) { + if (myPercentage >= gradeScaleSorted[i].minGrade) { + myGradeIndex = i; + break; + } + } + + function createSPlusDisclaimerImage() { + // rationale: we want to be very explicit whenever we're modifying anything in the bottom "Course Grade" box + // we put our tweaks here in the first place because it makes sense in the UI, and because it doesn't change with grade edits + // yet we want to be clear this is unofficial + // TODO discuss potentially better alternatives to either this particular or any img in general for making this clarification + return createSvgLogo("splus-coursegradebox-taint"); + } + + // points needed for next highest grade + if (myGradeIndex > 0) { + let targetGrade = gradeScaleSorted[myGradeIndex - 1]; + let targetPts = (targetGrade.minGrade / 100) * classTotal; + let neededPts = roundDecimal(targetPts - classPoints, 2); + + summaryPointsContainer.appendChild(createElement("div", ["total-points-wrapper"], {}, [ + createSPlusDisclaimerImage(), + createElement("span", ["total-points-title"], { textContent: "Points Needed:" }), + createElement("span", ["total-points-awarded"], { textContent: neededPts }), + createElement("span", ["total-points-possible"], { textContent: " for " + targetGrade.symbol }) + ])); + } + + if (myGradeIndex < gradeScaleSorted.length - 1) { + let targetGrade = gradeScaleSorted[myGradeIndex + 1]; + let targetPts = (gradeScaleSorted[myGradeIndex].minGrade / 100) * classTotal; + let bufferPts = roundDecimal(classPoints - targetPts, 2); + + summaryPointsContainer.appendChild(createElement("div", ["total-points-wrapper"], {}, [ + createSPlusDisclaimerImage(), + createElement("span", ["total-points-title"], { textContent: "Point Buffer:" }), + createElement("span", ["total-points-awarded"], { textContent: bufferPts }), + createElement("span", ["total-points-possible"], { textContent: " from " + targetGrade.symbol }) + ])); + } + } + } + + // Remove (no grading period) + if (isLAUSD()) { // To avoid making assumptions about how other schools use Schoology + for (let i = 1; i < periods.length; i++) { + periods[i].remove(); + } + } + })()); + } + + if (!document.location.search.includes("past") || document.location.search.split("past=")[1] != 1) { + let timeRow = document.getElementById("past-selector"); + let gradeModifLabelFirst = true; + if (timeRow == null) { + // basically a verbose null propagation + // timeRow = document.querySelector(".content-top-upper")?.insertAdjacentElement('afterend', document.createElement("div")) + let contentTopUpper = document.querySelector(".content-top-upper"); + if (contentTopUpper) { + timeRow = contentTopUpper.insertAdjacentElement('afterend', document.createElement("div")); + } + } + if (timeRow == null) { + let downloadBtn = document.querySelector("#main-inner .download-grade-wrapper"); + if (downloadBtn) { + let checkboxHolder = document.createElement("span"); + checkboxHolder.id = "splus-gradeedit-checkbox-holder"; + downloadBtn.prepend(checkboxHolder); + + downloadBtn.classList.add("splus-gradeedit-checkbox-holder-wrapper"); + + timeRow = checkboxHolder; + gradeModifLabelFirst = false; + } + } + + let timeRowLabel = createElement("label", ["modify-label"], { + htmlFor: "enable-modify" + }, [ + createElement("span", [], { textContent: "Enable what-if grades" }), + createElement("a", ["splus-grade-help-btn"], { + href: "https://schoologypl.us/docs/grades", + target: "_blank" + }, [createElement("span", ["icon-help"])]) + ]); + + if (gradeModifLabelFirst) { + timeRow.appendChild(timeRowLabel); + } + + timeRow.appendChild(createElement("input", ["splus-track-clicks"], { + type: "checkbox", + id: "enable-modify", + dataset: { + splusTrackingContext: "What-If Grades" + }, + onclick: function () { + let normalAssignRClickSelector = ".item-row:not(.dropped):not(.grade-add-indicator):not(.added-fake-assignment)"; + let addedAssignRClickSelector = ".item-row.added-fake-assignment:not(.dropped):not(.grade-add-indicator)"; + let droppedAssignRClickSelector = ".item-row.dropped:not(.grade-add-indicator)"; + + // any state change when editing has been disabled + if (Setting.getValue("apistatus") === "denied") { + if (confirm("This feature requires access to your Schoology API Key, which you have denied. Would you like to enable access?")) { + trackEvent("button_click", { + id: "api-denied-popup", + context: "What-If Grades", + value: "go-to-enabled", + legacyTarget: "api-denied-popup", + legacyAction: "go-to-enable", + legacyLabel: "What-If Grades" + }); + location.pathname = "/api"; + } else { + trackEvent("button_click", { + id: "api-denied-popup", + context: "What-If Grades", + value: "keep-disabled", + legacyTarget: "api-denied-popup", + legacyAction: "keep-disabled", + legacyLabel: "What-If Grades" + }); + } + } + else if (Setting.getValue("apistatus") === "blocked") { + if (confirm("This feature requires access to your Schoology API Key, which has unfortunately been blocked by your school. If you think this might not be right, you can click OK to try and enable access again.")) { + trackEvent("button_click", { + id: "api-blocked-popup", + context: "What-If Grades", + value: "go-to-enable", + legacyTarget: "api-blocked-popup", + legacyAction: "go-to-enable", + legacyLabel: "What-If Grades" + }); + location.pathname = "/api"; + } else { + trackEvent("button_click", { + id: "api-blocked-popup", + context: "What-If Grades", + value: "keep-blocked", + legacyTarget: "api-blocked-popup", + legacyAction: "keep-blocked", + legacyLabel: "What-If Grades" + }); + } + } + else if (editDisableReason && editDisableReason.causedByNoApiKey) { + location.pathname = "/api"; + } + else if (editDisableReason && !editDisableReason.allCausedBy403) { + Logger.error("Editing disabled due to error", editDisableReason); + + if (confirm("Grade editing has been disabled due to an error. If you are trying to use What If Grades on the grade report page, try going to an individual class gradebook instead. Would you like to report this issue? (It will help us fix it faster!)")) { + window.open(`${BUG_REPORT_FORM_LINK}${encodeURIComponent(JSON.stringify(editDisableReason))}`, "_blank"); + } + + document.getElementById("enable-modify").checked = false; + } + // enabling editing + else if (document.getElementById("enable-modify").checked) { + if (editDisableReason && editDisableReason.allCausedBy403) { + if (confirm("WARNING!!!\n\nYou have one or more missing assignments for which the total points are unknown due to restrictions put in place by your teacher. Grade editing may work in some categories if this is a weighted gradebook, however it will be disabled in others. We are working on a fix for this issue, but until then please click 'OK' to submit a bug report so we can gague how large this problem is. Thank you!")) { + window.open(`${BUG_REPORT_FORM_LINK}${encodeURIComponent(JSON.stringify(editDisableReason))}`, "_blank"); + } + } + + for (let edit of document.getElementsByClassName("grade-edit-indicator")) { + if (invalidCategories.includes(edit.dataset.parentId)) continue; + + edit.style.display = "unset"; + } + for (let edit of document.getElementsByClassName("grade-add-indicator")) { + if (invalidCategories.includes(edit.dataset.parentId)) continue; + + edit.style.display = "table-row"; + if (edit.previousElementSibling.classList.contains("item-row") && edit.previousElementSibling.classList.contains("last-row-of-tier")) { + edit.previousElementSibling.classList.remove("last-row-of-tier"); + } + } + for (let arrow of document.getElementsByClassName("injected-empty-category-expand-icon")) { + arrow.style.visibility = "visible"; + } + + let calculateMinimumGrade = function (element, desiredGrade, courseId) { + let gradeColContentWrap = element.querySelector(".grade-wrapper").parentElement; + + removeExceptionState(element, gradeColContentWrap); + + // TODO refactor the grade extraction + let noGrade = gradeColContentWrap.querySelector(".no-grade"); + let score = gradeColContentWrap.querySelector(".rounded-grade") || gradeColContentWrap.querySelector(".rubric-grade-value") || gradeColContentWrap.querySelector(".no-grade"); + let maxGrade = gradeColContentWrap.querySelector(".max-grade"); + let scoreVal = 0; + let maxVal = 0; + noGrade = noGrade == score; + + if (score && maxGrade) { + scoreVal = Number.parseFloat(score.textContent); + maxVal = Number.parseFloat(maxGrade.textContent.substring(3)); + } else if (element.querySelector(".exception-icon.missing")) { + let scoreValues = maxGrade.textContent.split("/"); + scoreVal = Number.parseFloat(scoreValues[0]); + maxVal = Number.parseFloat(scoreValues[1]); + } + + if (Number.isNaN(scoreVal)) { + scoreVal = 0; + score.classList.add("rounded-grade"); + score.classList.remove("no-grade"); + } + + if (!gradeColContentWrap.querySelector(".modified-score-percent-warning")) { + //gradeColContentWrap.getElementsByClassName("injected-assignment-percent")[0].style.paddingRight = "0"; + gradeColContentWrap.appendChild(generateScoreModifyWarning()); + gradesModified = true; + } + + let catId = element.dataset.parentId; + let catRow = Array.prototype.find.call(element.parentElement.getElementsByTagName("tr"), e => e.dataset.id == catId); + + let perId = catRow.dataset.parentId; + let perRow = Array.prototype.find.call(element.parentElement.getElementsByTagName("tr"), e => e.dataset.id == perId); + + let deltaScore = 0; + + // TODO refactor + // if the period (course) has a point value (unweighted), just work from there + let awardedPeriodPoints = perRow.querySelector(".grade-column-center"); + if (awardedPeriodPoints && awardedPeriodPoints.textContent.trim().length !== 0) { + // awarded grade in our constructed element contains both rounded and max + let perScoreElem = awardedPeriodPoints.querySelector(".rounded-grade"); + let perMaxElem = awardedPeriodPoints.querySelector(".max-grade"); + let perScore = Number.parseFloat(perScoreElem.textContent); + let perMax = Number.parseFloat(perMaxElem.textContent.substring(3)); + + if (noGrade) { + perMax += maxVal; + } + + // (perScore + x) / perMax = desiredGrade + // solve for x: + deltaScore = (desiredGrade * perMax) - perScore; + } else { + // weighted + + // get this out of the way so it doesn't ruin later calculations + if (noGrade) { + recalculateCategoryScore(catRow, 0, maxVal, true, courseId); + recalculatePeriodScore(perRow, 0, maxVal, true, courseId); + noGrade = false; + } + + // get category score and category weight + let awardedCategoryPoints = catRow.querySelector(".rounded-grade").parentNode; + let catScoreElem = awardedCategoryPoints.querySelector(".rounded-grade"); + let catMaxElem = awardedCategoryPoints.querySelector(".max-grade"); + let catScore = Number.parseFloat(catScoreElem.textContent); + let catMax = Number.parseFloat(catMaxElem.textContent.substring(3)); + + if (noGrade) { + catMax += maxVal; + } + + // TODO refactor + let total = 0; + let totalPercentWeight = 0; + let catWeight = 0; // 0 to 1 + for (let category of perRow.parentElement.querySelectorAll(`.category-row[data-parent-id="${perRow.dataset.id}"]`)) { + let weightPercentElement = category.getElementsByClassName("percentage-contrib")[0]; + if (!weightPercentElement) { + continue; + } + + let weightPercent = weightPercentElement.textContent; + let col = category.getElementsByClassName("grade-column-right")[0]; + + let colMatch = col ? col.textContent.match(/(\d+\.?\d*)%/) : null; + + if (colMatch) { + let scorePercent = Number.parseFloat(colMatch[1]); + if ((scorePercent || scorePercent === 0) && !Number.isNaN(scorePercent)) { + total += (weightPercent.slice(1, -2) / 100) * scorePercent; + let weight = Number.parseFloat(weightPercent.slice(1, -2)); + totalPercentWeight += weight; + if (category.dataset.id == catId) { + catWeight = weight / 100; + } + } + } + } + + totalPercentWeight /= 100; + + // if only some categories have assignments, adjust the total accordingly + // if weights are more than 100, this assumes that it's correct as intended (e.c.), I won't mess with it + if (totalPercentWeight > 0 && totalPercentWeight < 1) { + // some categories are specified, but weights don't quite add to 100 + // scale up known grades + total /= totalPercentWeight; + catWeight /= totalPercentWeight; + // epsilon because floating point + } else if (totalPercentWeight < 0.00001) { + total = 100; + } + + // normalize total: 0 to 1+ [e.c.], total course score + total /= 100; + + // calculate the worth in percentage points of one point in our category + let catPointWorth = catWeight / catMax; + + // total + (catPointWorth*deltaScore) = desiredGrade + // solve: + deltaScore = (desiredGrade - total) / catPointWorth; + } + + if (deltaScore < -scoreVal) { + deltaScore = -scoreVal; + } + + // ?: Using Math.ceil ensures finalGrade >= desiredGrade when possible + deltaScore = Math.ceil(deltaScore * 100) / 100; + + const finalGrade = Math.round((scoreVal + deltaScore) * 100) / 100; + if (score) { + // TODO refactor: we already have our DOM elements + score.title = finalGrade; + score.textContent = finalGrade; + } + + prepareScoredAssignmentGrade(element.querySelector(".injected-assignment-percent"), finalGrade, maxVal); + recalculateCategoryScore(catRow, deltaScore, noGrade ? maxVal : 0, true, courseId); + recalculatePeriodScore(perRow, deltaScore, noGrade ? maxVal : 0, true, courseId); + }; + + let dropGradeThis = function () { + trackEvent("context_menu_click", { + id: "Drop", + context: "What-If Grades", + legacyTarget: "assignment", + legacyAction: "drop", + legacyLabel: "What-If Grades" + }); + this[0].classList.add("dropped"); + // alter grade + let gradeColContentWrap = this[0].querySelector(".grade-wrapper").parentElement; + // TODO refactor the grade extraction + let score = gradeColContentWrap.querySelector(".rounded-grade") || gradeColContentWrap.querySelector(".rubric-grade-value"); + let maxGrade = gradeColContentWrap.querySelector(".max-grade"); + let scoreVal = 0; + let maxVal = 0; + + if (score && maxGrade) { + scoreVal = Number.parseFloat(score.textContent); + maxVal = Number.parseFloat(maxGrade.textContent.substring(3)); + } else if (this[0].classList.contains("contains-exception")) { + let scoreValues = maxGrade.textContent.split("/"); + scoreVal = Number.parseFloat(scoreValues[0]); + maxVal = Number.parseFloat(scoreValues[1]); + if (Number.isNaN(scoreVal)) { + // exception states are 0 or -, always + // this might be NaN because it's parsing a space + scoreVal = 0; + } + if (!this[0].querySelector(".exception-icon.missing")) { + // non-missing exception means uncounted (incomplete or excused) + // set scores to 0 so we make no mathematical change + scoreVal = 0; + maxVal = 0; + } + } + + if (!gradeColContentWrap.querySelector(".modified-score-percent-warning")) { + //gradeColContentWrap.getElementsByClassName("injected-assignment-percent")[0].style.paddingRight = "0"; + gradeColContentWrap.appendChild(generateScoreModifyWarning()); + gradesModified = true; + } + + let catId = this[0].dataset.parentId; + let catRow = Array.prototype.find.call(this[0].parentElement.getElementsByTagName("tr"), e => e.dataset.id == catId); + + let perId = catRow.dataset.parentId; + let perRow = Array.prototype.find.call(this[0].parentElement.getElementsByTagName("tr"), e => e.dataset.id == perId); + + let courseId = perRow.dataset.parentId; + + recalculateCategoryScore(catRow, -scoreVal, -maxVal, true, courseId); + recalculatePeriodScore(perRow, -scoreVal, -maxVal, true, courseId); + }; + + let undroppedAssignItemSet = { + drop: { + name: "Drop", + callback: dropGradeThis + }, + delete: { + name: "Delete", + callback: function () { + dropGradeThis.bind(this)(); + // shouldn't need to worry about any last-row-of-tier stuff because this will always be followed by an Add Assignment indicator + this[0].remove(); + } + }, + separator: "-----", + calculateMinGrade: { + name: "Calculate Minimum Grade", + callback: function (key, opt) { + // TODO refactor grade extraction + // get course letter grade + let catId = this[0].dataset.parentId; + let catRow = Array.prototype.find.call(this[0].parentElement.getElementsByTagName("tr"), e => e.dataset.id == catId); + let perId = catRow.dataset.parentId; + let perRow = Array.prototype.find.call(this[0].parentElement.getElementsByTagName("tr"), e => e.dataset.id == perId); + + // TODO refactor + // (this) tr -> tbody -> table -> div.gradebook-course-grades -> relevant div + let courseId = Number.parseInt(/course-(\d+)$/.exec(this[0].parentElement.parentElement.parentElement.parentElement.id)[1]); + + let gradingScale = Setting.getValue("getGradingScale")(courseId); + + let courseGrade = getLetterGrade(gradingScale, Number.parseFloat(/\d+(\.\d+)%/.exec(perRow.querySelector(".grade-column-right").firstElementChild.textContent)[0].slice(0, -1))); + + let desiredPercentage = 0.9; + + // letter to percent - "in" for property enumeration + for (let gradeValue in gradingScale) { + if (gradingScale[gradeValue] == courseGrade) { + desiredPercentage = Number.parseFloat(gradeValue) / 100; + break; + } + } + + trackEvent("context_menu_click", { + id: "Calculate Minimum Grade", + context: "What-If Grades", + legacyTarget: "assignment", + legacyAction: "calc-min", + legacyLabel: "What-If Grades" + }); + calculateMinimumGrade(this[0], desiredPercentage, courseId); + }, + items: {} + } + }; + + let undroppedAssignContextMenuItems = { + drop: undroppedAssignItemSet.drop, + separator: undroppedAssignItemSet.separator, + calculateMinGrade: undroppedAssignItemSet.calculateMinGrade + }; + + for (let courseElement of document.getElementsByClassName("gradebook-course")) { + let baseContextMenuObject = {}; + let calcMinFor = {}; + baseContextMenuObject.items = {}; + Object.assign(baseContextMenuObject.items, undroppedAssignContextMenuItems); + baseContextMenuObject.items.calculateMinGrade = {}; + Object.assign(baseContextMenuObject.items.calculateMinGrade, undroppedAssignContextMenuItems.calculateMinGrade); + baseContextMenuObject.items.calculateMinGrade.items = calcMinFor; + + baseContextMenuObject.selector = "#" + courseElement.id + " "; + + let courseId = /\d+$/.exec(courseElement.id)[0]; + + // TODO if grade scale is updated while this page is loaded (i.e. after this code runs) what to do + let gradingScale = Setting.getValue("getGradingScale")(courseId); + + for (let gradeValue of Object.keys(gradingScale).sort((a, b) => b - a)) { + let letterGrade = gradingScale[gradeValue]; + calcMinFor["calculateMinGradeFor" + gradeValue] = { + name: "For " + letterGrade + " (" + gradeValue + "%)", + callback: function (key, opt) { + trackEvent("context_menu_click", { + id: "Calculate Minimum Grade For...", + context: "What-If Grades", + value: letterGrade, + legacyTarget: "assignment", + legacyAction: `calc-min-for-${letterGrade}`, + legacyLabel: "What-If Grades" + }); + calculateMinimumGrade(this[0], Number.parseFloat(gradeValue) / 100, courseId); + } + }; + } + + calcMinFor.separator1 = "-----"; + + calcMinFor.calculateMinGradeForCustom = { + name: "For Custom Value", + callback: function (key, opt) { + trackEvent("context_menu_click", { + id: "Calculate Minimum Grade For Custom Value", + context: "What-If Grades", + value: "custom-value", + legacyTarget: "assignment", + legacyAction: "calc-min-for-custom", + legacyLabel: "What-If Grades" + }); + + let value = prompt("Please enter a grade to calculate for (a number on the scale of 0 to 100)"); + + if (!Number.isNaN(value) && !Number.isNaN(Number.parseFloat(value))) { + // if a number, calculate + calculateMinimumGrade(this[0], Number.parseFloat(value) / 100, courseId); + } else { + alert("Invalid number") + } + } + }; + + calcMinFor.separator2 = "-----"; + calcMinFor.courseOptions = { + name: "Change Grade Boundaries", + callback: function () { + let courseElem = this[0].closest(".gradebook-course"); + let titleElem = SINGLE_COURSE ? document.querySelector(".page-title") : courseElem.querySelector(".gradebook-course-title"); + trackEvent("context_menu_click", { + id: "Change Grade Boundaries", + context: "What-If Grades", + legacyTarget: "assignment", + legacyAction: "change-boundaries", + legacyLabel: "What-If Grades" + }); + openModal("course-settings-modal", { + courseId: courseElem.id.match(/\d+/)[0], + courseName: titleElem.querySelector("a span:nth-child(3)") ? titleElem.querySelector("a span:nth-child(2)").textContent : titleElem.innerText.split('\n')[0] + }); + } + }; + + let normalContextMenuObject = Object.assign({}, baseContextMenuObject); + normalContextMenuObject.selector += normalAssignRClickSelector; + + + let addedContextMenuObject = Object.assign({}, baseContextMenuObject); + addedContextMenuObject.selector += addedAssignRClickSelector; + // replace drop with delete + addedContextMenuObject.items = Object.assign({}, baseContextMenuObject.items); + addedContextMenuObject.items.drop = undroppedAssignItemSet.delete; + + $.contextMenu(normalContextMenuObject); + $.contextMenu(addedContextMenuObject); + } + + $.contextMenu({ + selector: droppedAssignRClickSelector, + items: { + undrop: { + name: "Undrop", + callback: function (key, opt) { + trackEvent("context_menu_click", { + id: "Undrop", + context: "What-If Grades", + legacyTarget: "assignment", + legacyAction: "undrop", + legacyLabel: "What-If Grades" + }); + this[0].classList.remove("dropped"); + // alter grade + let gradeColContentWrap = this[0].querySelector(".grade-wrapper").parentElement; + removeExceptionState(this[0], gradeColContentWrap); + // TODO refactor the grade extraction + let score = gradeColContentWrap.querySelector(".rounded-grade") || gradeColContentWrap.querySelector(".rubric-grade-value"); + let maxGrade = gradeColContentWrap.querySelector(".max-grade"); + let scoreVal = 0; + let maxVal = 0; + + if (score && maxGrade) { + scoreVal = Number.parseFloat(score.textContent); + maxVal = Number.parseFloat(maxGrade.textContent.substring(3)); + } else if (this[0].querySelector(".exception-icon.missing")) { + let scoreValues = maxGrade.textContent.split("/"); + scoreVal = Number.parseFloat(scoreValues[0]); + maxVal = Number.parseFloat(scoreValues[1]); + } + + if (!gradeColContentWrap.querySelector(".modified-score-percent-warning")) { + //gradeColContentWrap.getElementsByClassName("injected-assignment-percent")[0].style.paddingRight = "0"; + gradeColContentWrap.appendChild(generateScoreModifyWarning()); + gradesModified = true; + } + + let catId = this[0].dataset.parentId; + let catRow = Array.prototype.find.call(this[0].parentElement.getElementsByTagName("tr"), e => e.dataset.id == catId); + + let perId = catRow.dataset.parentId; + let perRow = Array.prototype.find.call(this[0].parentElement.getElementsByTagName("tr"), e => e.dataset.id == perId); + + let courseId = perRow.dataset.parentId; + + recalculateCategoryScore(catRow, scoreVal, maxVal, true, courseId); + recalculatePeriodScore(perRow, scoreVal, maxVal, true, courseId); + } + } + } + }); + + for (let kabob of document.getElementsByClassName("kabob-menu")) { + if (invalidCategories.includes(kabob.dataset.parentId)) continue; + + kabob.classList.remove("hidden"); + } + // uncheck the grades modify box without having modified grades + } else if (!gradesModified) { + for (let edit of document.getElementsByClassName("grade-edit-indicator")) { + edit.style.display = "none"; + } + for (let edit of document.getElementsByClassName("grade-add-indicator")) { + edit.style.display = "none"; + if (edit.previousElementSibling.classList.contains("item-row") && !edit.previousElementSibling.classList.contains("last-row-of-tier")) { + edit.previousElementSibling.classList.add("last-row-of-tier"); + } + } + for (let kabob of document.getElementsByClassName("kabob-menu")) { + kabob.classList.add("hidden"); + } + for (let courseElement of document.getElementsByClassName("gradebook-course")) { + $.contextMenu("destroy", "#" + courseElement.id + " " + normalAssignRClickSelector); + $.contextMenu("destroy", "#" + courseElement.id + " " + addedAssignRClickSelector); + } + for (let arrow of document.getElementsByClassName("injected-empty-category-expand-icon")) { + arrow.style.visibility = "hidden"; + } + $.contextMenu("destroy", droppedAssignRClickSelector); + // uncheck the edit checkbox, with modified grades existing: prompt: does user confirm? + } else if (confirm("Disabling grade edits now will reload the page and erase all existing modified grades. Proceed?")) { + // not going to try to undo any grade modifications + document.location.reload(); + // attempted to disable grade editing but backed out of confirmation prompt + } else { + document.getElementById("enable-modify").checked = true; + } + } + })); + + if (!gradeModifLabelFirst) { + timeRow.appendChild(timeRowLabel); + } + } + + for (let courseTask of courseLoadTasks) { + await courseTask; + } + + if (!document.location.search.includes("past") || document.location.search.split("past=")[1] != 1) { + if (Setting.getValue("orderClasses") == "period") { + for (let course of coursesByPeriod) { + if (course) { + course.parentElement.appendChild(course); + } + } + } + } + + function getLetterGrade(gradingScale, percentage) { + let sorted = Object.keys(gradingScale).sort((a, b) => b - a); + for (let s of sorted) { + if (percentage >= Number.parseInt(s)) { + return gradingScale[s]; + } + } + return "?"; + } + + function queueNonenteredAssignment(assignment, courseId, period, category) { + let noGrade = assignment.getElementsByClassName("no-grade")[0]; + + // awarded grade present for assignments with letter-grade-only scores (numeric value hidden) + let awardedGrade = assignment.getElementsByClassName("awarded-grade")[0]; + let letterGradeOnly = false; + + if (!noGrade && awardedGrade) { + Logger.log(`Found assignment (ID ${assignment.dataset.id.substr(2)}) with only letter-grade showing`); + letterGradeOnly = true; + + awardedGrade.textContent += " "; + noGrade = document.createElement("span"); + noGrade.classList.add("no-grade"); + noGrade.textContent = "—"; + awardedGrade.insertAdjacentElement("afterend", noGrade); + } + + if (!noGrade) { + Logger.log(`Error loading potentially nonentered assignment with ID ${assignment.dataset.id.substr(2)}`); + return; + } + + if (noGrade.parentElement.classList.contains("exception-grade-wrapper")) { + noGrade.remove(); + assignment.classList.add("contains-exception") + // an exception case + // now we just have to be careful to avoid double-counting + noGrade = assignment.querySelector(".exception .exception-icon"); + if (noGrade) { + // the text gets in the way + let exceptionDesc = assignment.querySelector(".exception .exception-text"); + noGrade.title = exceptionDesc.textContent; + exceptionDesc.remove(); + } + } + + if (noGrade && assignment.dataset.id) { + // do this while the other operation is happening so we don't block the page load + // don't block on it + + let maxGrade = document.createElement("span"); + maxGrade.classList.add("max-grade"); + maxGrade.classList.add("no-grade"); + maxGrade.textContent = " / —"; + noGrade.insertAdjacentElement("afterend", maxGrade); + + let f = async () => { + let domAssignId = assignment.dataset.id.substr(2); + Logger.log(`Fetching max points for (nonentered) assignment ${domAssignId}`); + + let response = null; + let firstTryError = null; + + try { + response = await fetchApi(`sections/${courseId}/assignments/${domAssignId}`); + } catch (err) { + firstTryError = err; + } + + if (response && !response.ok) { + firstTryError = { status: response.status, error: response.statusText }; + } else if (response) { + try { + let json = await response.json(); + + if (json && json.max_points !== undefined) { + // success case + // note; even if maxGrade is removed from the DOM, this will still work + maxGrade.textContent = " / " + json.max_points; + maxGrade.classList.remove("no-grade"); + } else { + firstTryError = "JSON returned without max points"; + } + } catch (err) { + firstTryError = err; + } + } else if (!firstTryError) { + firstTryError + "Unknown error fetching API response"; + } + + if (!firstTryError && !letterGradeOnly) return; + + if (firstTryError) { + Logger.log(`Error directly fetching max points for (nonentered) assignment ${domAssignId}, reverting to list-search`); + } + if (letterGradeOnly) { + Logger.log(`Finding grade for letter-grade-only assignment ${domAssignId} from list-search`); + } + + try { + response = await fetchApi(`users/${getUserId()}/grades?section_id=${courseId}`); + if (!response.ok) { + throw { status: response.status, error: response.statusText }; + } + let json = await response.json(); + + if (json && json.section.length > 0) { + // success case + let jsonAssignment = json.section[0].period.flatMap(p => p.assignment).filter(x => x.assignment_id == Number.parseInt(domAssignId))[0]; + + if (letterGradeOnly && jsonAssignment.grade !== undefined) { + let numericGradeValueSpan = createElement( + "span", + ["numeric-grade-value"], + {}, + [ + createElement( + "span", + ["rounded-grade"], + {title: String(jsonAssignment.grade), textContent: String(jsonAssignment.grade)} + ) + ] + ); + awardedGrade.insertAdjacentElement("beforeend", numericGradeValueSpan); + noGrade.outerHTML = ""; + + recalculateCategoryScore(category, Number.parseFloat(jsonAssignment.grade), Number.parseFloat(jsonAssignment.max_points), false, courseId); + recalculatePeriodScore(period, Number.parseFloat(jsonAssignment.grade), Number.parseFloat(jsonAssignment.max_points), false, courseId); + } + + if (firstTryError) { + // note; even if maxGrade is removed from the DOM, this will still work + maxGrade.textContent = " / " + jsonAssignment.max_points; + maxGrade.classList.remove("no-grade"); + } + } else { + if (letterGradeOnly) { + addEditDisableReason("Letter grade only assignment can't load point values", true, false); + invalidCategories.push(category.dataset.id); + } + + throw "List search failed to obtain meaningful response"; + } + } catch (err) { + throw { listSearchErr: err, firstTryError: firstTryError }; + } + }; + fetchQueue.push([f, 0]); + } + + // td-content-wrapper + noGrade.parentElement.appendChild(document.createElement("br")); + let injectedPercent = createElement("span", ["percentage-grade", "injected-assignment-percent"], { textContent: "N/A" }); + noGrade.parentElement.appendChild(injectedPercent); + } + + function prepareScoredAssignmentGrade(spanPercent, score, max) { + spanPercent.textContent = max === 0 ? "EC" : `${Math.round(score * 100 / max)}%`; + spanPercent.title = max === 0 ? "Extra Credit" : `${score * 100 / max}%`; + if (!spanPercent.classList.contains("max-grade")) { + spanPercent.classList.add("max-grade"); + } + if (!spanPercent.classList.contains("injected-assignment-percent")) { + spanPercent.classList.add("injected-assignment-percent"); + } + } + + function setGradeText(gradeElement, sum, max, row, doNotDisplay) { + if (gradeElement) { + let courseId = row.parentElement.firstElementChild.dataset.id; + // currently there exists a letter grade here, we want to put a point score here and move the letter grade + let text = gradeElement.parentElement.textContent; + let textContent = gradeElement.parentElement.textContent; + gradeElement.parentElement.classList.add("grade-column-center"); + //gradeElement.parentElement.style.textAlign = "center"; + //gradeElement.parentElement.style.paddingRight = "30px"; + gradeElement.innerHTML = ""; + // create the elements for our point score + gradeElement.appendChild(createElement("span", ["rounded-grade"], { textContent: doNotDisplay ? "" : Math.round(sum * 100) / 100 })); + gradeElement.appendChild(createElement("span", ["max-grade"], { textContent: doNotDisplay ? "" : ` / ${Math.round(max * 100) / 100}` })); + // move the letter grade over to the right + span = row.querySelector(".comment-column").firstChild; + span.textContent = text; + + addLetterGrade(span, courseId); + + // restyle the right hand side + span.parentElement.classList.remove("comment-column"); + span.parentElement.classList.add("grade-column"); + span.parentElement.classList.add("grade-column-right"); + //span.style.cssFloat = "right"; //maybe remove + //span.style.color = "#3aa406"; + //span.style.fontWeight = "bold"; + } + } + + function addLetterGrade(elem, courseId) { + let gradingScale = Setting.getValue("getGradingScale")(courseId); + if (Setting.getValue("customScales") != "disabled" && elem.textContent.match(/^\d+\.?\d*%/) !== null) { + let percent = Number.parseFloat(elem.textContent.substr(0, elem.textContent.length - 1)); + let letterGrade = getLetterGrade(gradingScale, percent); + elem.textContent = `${letterGrade} (${percent}%)`; + elem.title = `Letter grade calculated by Schoology Plus using the following grading scale:\n${Object.keys(gradingScale).sort((a, b) => a - b).reverse().map(x => `${gradingScale[x]}: ${x}%`).join('\n')}\nTo change this grading scale, find 'Course Options' on the page for this course`; + } + } + + function generateScoreModifyWarning() { + return createElement("img", ["modified-score-percent-warning"], { + src: chrome.runtime.getURL("imgs/exclamation-mark.svg"), + title: "This grade has been modified from its true value." + }); + } + + function recalculateCategoryScore(catRow, deltaPoints, deltaMax, warn = true, courseId = null) { + // category always has a numeric score, unlike period + // awarded grade in our constructed element contains both rounded and max + let awardedCategoryPoints = catRow.querySelector(".rounded-grade").parentNode; + let catScoreElem = awardedCategoryPoints.querySelector(".rounded-grade"); + let catMaxElem = awardedCategoryPoints.querySelector(".max-grade"); + let newCatScore = Number.parseFloat(catScoreElem.textContent) + deltaPoints; + let newCatMax = Number.parseFloat(catMaxElem.textContent.substring(3)) + deltaMax; + catScoreElem.textContent = roundDecimal(newCatScore, 2); + catMaxElem.textContent = " / " + roundDecimal(newCatMax, 2); + if (warn && !awardedCategoryPoints.querySelector(".modified-score-percent-warning")) { + awardedCategoryPoints.appendChild(generateScoreModifyWarning()); + } + // category percentage + // need to recalculate + // content wrapper in right grade col + let awardedCategoryPercentContainer = catRow.querySelector(".grade-column-right").firstElementChild; + let awardedCategoryPercent = awardedCategoryPercentContainer; + // clear existing percentage indicator + while (awardedCategoryPercent.firstChild) { + awardedCategoryPercent.firstChild.remove(); + } + awardedCategoryPercent.appendChild(document.createElement("span")); + awardedCategoryPercent = awardedCategoryPercent.firstElementChild; + awardedCategoryPercent.classList.add("awarded-grade"); + awardedCategoryPercent.appendChild(document.createElement("span")); + awardedCategoryPercent = awardedCategoryPercent.firstElementChild; + awardedCategoryPercent.classList.add("numeric-grade"); + awardedCategoryPercent.classList.add("primary-grade"); + awardedCategoryPercent.appendChild(document.createElement("span")); + awardedCategoryPercent = awardedCategoryPercent.firstElementChild; + awardedCategoryPercent.classList.add("rounded-grade"); + + let newCatPercent = (newCatScore / newCatMax) * 100; + awardedCategoryPercent.title = newCatPercent + "%"; + awardedCategoryPercent.textContent = (Math.round(newCatPercent * 100) / 100) + "%"; + + if (warn && !awardedCategoryPercentContainer.querySelector(".modified-score-percent-warning")) { + awardedCategoryPercentContainer.prepend(generateScoreModifyWarning()); + } + if (courseId) { + addLetterGrade(awardedCategoryPercent, courseId) + } + } + + function recalculatePeriodScore(perRow, deltaPoints, deltaMax, warn = true, courseId = null) { + let awardedPeriodPercentContainer = perRow.querySelector(".grade-column-right").firstElementChild; + let awardedPeriodPercent = awardedPeriodPercentContainer; + // clear existing percentage indicator + while (awardedPeriodPercent.firstChild) { + awardedPeriodPercent.firstChild.remove(); + } + awardedPeriodPercent.appendChild(document.createElement("span")); + awardedPeriodPercent = awardedPeriodPercent.firstElementChild; + awardedPeriodPercent.classList.add("awarded-grade"); + awardedPeriodPercent.appendChild(document.createElement("span")); + awardedPeriodPercent = awardedPeriodPercent.firstElementChild; + awardedPeriodPercent.classList.add("numeric-grade"); + awardedPeriodPercent.classList.add("primary-grade"); + awardedPeriodPercentContainer = awardedPeriodPercent; + awardedPeriodPercent.appendChild(document.createElement("span")); + awardedPeriodPercent = awardedPeriodPercent.firstElementChild; + awardedPeriodPercent.classList.add("rounded-grade"); + + // now period (semester) + // might have a numeric score (weighting => no numeric, meaning we can assume unweighted if present) + let awardedPeriodPoints = perRow.querySelector(".grade-column-center"); + if (awardedPeriodPoints && awardedPeriodPoints.textContent.trim().length !== 0) { + // awarded grade in our constructed element contains both rounded and max + let perScoreElem = awardedPeriodPoints.querySelector(".rounded-grade"); + let perMaxElem = awardedPeriodPoints.querySelector(".max-grade"); + let newPerScore = Number.parseFloat(perScoreElem.textContent) + deltaPoints; + let newPerMax = Number.parseFloat(perMaxElem.textContent.substring(3)) + deltaMax; + perScoreElem.textContent = roundDecimal(newPerScore, 2); + perMaxElem.textContent = " / " + roundDecimal(newPerMax, 2); + if (warn && !awardedPeriodPoints.querySelector(".modified-score-percent-warning")) { + awardedPeriodPoints.appendChild(generateScoreModifyWarning()); + } + + // go ahead and calculate period percentage here since we know it's unweighted + let newPerPercent = (newPerScore / newPerMax) * 100; + awardedPeriodPercent.title = newPerPercent + "%"; + awardedPeriodPercent.textContent = (Math.round(newPerPercent * 100) / 100) + "%"; + } else { + let total = 0; + let totalPercentWeight = 0; + for (let category of perRow.parentElement.querySelectorAll(`.category-row[data-parent-id="${perRow.dataset.id}"]`)) { + let weightPercentElement = category.getElementsByClassName("percentage-contrib")[0]; + if (!weightPercentElement) { + continue; + } + let weightPercent = weightPercentElement.textContent; + let col = category.getElementsByClassName("grade-column-right")[0]; + let colMatch = col ? col.textContent.match(/(\d+\.?\d*)%/) : null; + if (colMatch) { + let scorePercent = Number.parseFloat(colMatch[1]); + if (!Number.isNaN(scorePercent)) { + total += (weightPercent.slice(1, -2) / 100) * scorePercent; + totalPercentWeight += Number.parseFloat(weightPercent.slice(1, -2)); + } + } + } + + totalPercentWeight /= 100; + + // if only some categories have assignments, adjust the total accordingly + // if weights are more than 100, this assumes that it's correct as intended (e.c.), I won't mess with it + if (totalPercentWeight > 0 && totalPercentWeight < 1) { + // some categories are specified, but weights don't quite add to 100 + // scale up known grades + total /= totalPercentWeight; + // epsilon because floating point + } else if (totalPercentWeight < 0.00001) { + total = 100; + } + + awardedPeriodPercent.title = total + "%"; + awardedPeriodPercent.textContent = (Math.round(total * 100) / 100) + "%"; + } + + if (courseId) { + addLetterGrade(awardedPeriodPercent, courseId) + } + + awardedPeriodPercentContainer = perRow.querySelector(".grade-column-right").firstElementChild + if (warn && !awardedPeriodPercentContainer.querySelector(".modified-score-percent-warning")) { + awardedPeriodPercentContainer.prepend(generateScoreModifyWarning()); + } + } + + function parseAssignmentNumerator(numString, denomFloat, courseId) { + if (Number.isNaN(denomFloat)) { + return Number.NaN; + } + + let numFloat; + let percentMatch = /^(-?[0-9]+(\.[0-9]+)?)%$/.exec(numString); + + if (Number.isFinite(denomFloat) && percentMatch && percentMatch[1]) { + numFloat = denomFloat * Number.parseFloat(percentMatch[1]) / 100; + + if (!Number.isNaN(numFloat)) { + return numFloat; + } + } + + numFloat = Number.parseFloat(numString); + + if (!Number.isNaN(numFloat)) { + return numFloat; + } + + + if (Number.isFinite(denomFloat) && courseId) { + let gradingScale = Setting.getValue("getGradingScale")(courseId); + for (let gradeScalePercent in gradingScale) { + let letterSymbol = gradingScale[gradeScalePercent]; + if (numString == letterSymbol) { + numFloat = (gradeScalePercent / 100) * denomFloat; + break; + } + } + } + + return Number.isFinite(numFloat) ? numFloat : Number.NaN; + } + + function removeExceptionState(assignment, gradeColContentWrap, exceptionIcon, score, maxGrade) { + if (!gradeColContentWrap) { + gradeColContentWrap = assignment.querySelector(".grade-column .td-content-wrapper"); + } + + let gradeWrapper = gradeColContentWrap.querySelector(".grade-wrapper"); + + if (!exceptionIcon) { + exceptionIcon = gradeColContentWrap.querySelector(".exception-icon"); + if (!exceptionIcon) { + return {}; + } + } + + if (!score) { + score = gradeColContentWrap.querySelector(".rounded-grade") || gradeColContentWrap.querySelector(".rubric-grade-value"); + } + + if (!maxGrade) { + maxGrade = gradeColContentWrap.querySelector(".max-grade"); + } + + let retVars = {}; + + // the only exception which counts against the user is "missing" + let missing = exceptionIcon.classList.contains("missing"); + let scoreElem = createElement("span", [missing ? "rounded-grade" : "no-grade"], { textContent: missing ? "0" : "—" }); + retVars.editElem = scoreElem; + retVars.initPts = 0; + if (missing) { + retVars.score = scoreElem; + scoreElem = createElement("span", ["awarded-grade"], {}, [retVars.score, maxGrade]); + retVars.initMax = Number.parseFloat(maxGrade.textContent.substring(3)); + } else { + retVars.initMax = 0; + retVars.noGrade = scoreElem; + } + // reorganize + let elemToRemove = exceptionIcon.parentElement.parentElement; + let nodesToMoveHolder = exceptionIcon.parentElement; + + exceptionIcon.insertAdjacentElement('afterend', scoreElem); + exceptionIcon.remove(); + + let nodesToMove = Array.from(nodesToMoveHolder.childNodes); + let nodesAfterMove = nodesToMove.splice(nodesToMove.findIndex(x => x.tagName == "BR")); + nodesToMove.reverse(); + nodesAfterMove.reverse(); + for (let i = 0; i < nodesToMove.length; i++) { + gradeColContentWrap.insertAdjacentElement('afterbegin', nodesToMove[i]); + } + for (let i = 0; i < nodesAfterMove.length; i++) { + gradeWrapper.insertAdjacentElement('afterend', nodesAfterMove[i]); + } + elemToRemove.remove(); + + assignment.classList.remove("contains-exception"); + } + + function createEditListener(assignment, gradeColContentWrap, catRow, perRow, finishedCallback) { + return function () { + trackEvent("button_click", { + id: "change-assignment-grade", + context: "What-If Grades", + legacyTarget: "assignment", + legacyAction: "change-grade", + legacyLabel: "What-If Grades" + }); + removeExceptionState(assignment, gradeColContentWrap); + + let noGrade = gradeColContentWrap.querySelector(".no-grade"); + let score = gradeColContentWrap.querySelector(".rounded-grade") || gradeColContentWrap.querySelector(".rubric-grade-value"); + // note that this will always return (for our injected percentage element) + let maxGrade = gradeColContentWrap.querySelector(".max-grade"); + let editElem; + let initPts; + let initMax; + if (noGrade) { + editElem = noGrade; + initPts = 0; + initMax = 0; + if (maxGrade && maxGrade.classList.contains("no-grade")) { + maxGrade.remove(); + maxGrade = null; + } + } + if (score && maxGrade) { + editElem = score; + initPts = Number.parseFloat(score.textContent); + initMax = Number.parseFloat(maxGrade.textContent.substring(3)); + } + + if (!editElem || editElem.classList.contains("student-editable")) { + return; + } + + editElem.classList.add("student-editable"); + editElem.contentEditable = true; + + // TODO refactor + // (this) tr -> tbody -> table -> div.gradebook-course-grades -> relevant div + let courseId = Number.parseInt(/course-(\d+)$/.exec(perRow.parentElement.parentElement.parentElement.parentElement.id)[1]); + + // TODO blur v focusout + let submitFunc = function () { + if (!editElem.classList.contains("student-editable")) { + // we've already processed this event, ignore and return for cleanup + return true; + } + + let userScore; + let userMax; + if (noGrade) { + // regex capture and check + if (maxGrade) { + // noGrade+maxGrade = inserted maxGrade from an API call + // initDenom = 0; newDenom = whatever is in that textfield + userMax = Number.parseFloat(maxGrade.textContent.substring(3)); + userScore = parseAssignmentNumerator(noGrade.textContent, userMax, courseId); + } else { + let regexResult = /^(-?\d+(\.\d+)?)\s*\/\s*(-?\d+(\.\d+)?)$/.exec(editElem.textContent); + if (!regexResult) { + return false; + } + userMax = Number.parseFloat(regexResult[3]); + userScore = parseAssignmentNumerator(regexResult[1], userMax, courseId); + } + if (Number.isNaN(userScore) || Number.isNaN(userMax)) { + return false; + } + } else if (score) { + // user entered number must be a numeric + userScore = parseAssignmentNumerator(score.textContent, initMax, courseId); + userMax = initMax; + if (Number.isNaN(userScore)) { + return false; + } + } else { + // ??? + Logger.warn("unexpected case of field type in editing grade"); + return false; + } + + // we've established a known new score and max, with an init score and max to compare to + let deltaPoints = userScore - initPts; + let deltaMax = userMax - initMax; + // first, replace no grades + if (noGrade) { + if (!maxGrade) { + maxGrade = createElement("span", ["max-grade"], { textContent: " / " + userMax }); + gradeColContentWrap.prepend(maxGrade); + } + let awardedGrade = createElement("span", ["awarded-grade"]); + score = createElement("span", ["rounded-grade"], { title: userScore, textContent: userScore }); + awardedGrade.appendChild(score); + gradeColContentWrap.prepend(score); + noGrade.remove(); + } else { + // we already have our DOM elements + score.title = userScore; + score.textContent = userScore; + // will not have changed but still + maxGrade.textContent = " / " + userMax; + score.contentEditable = false; + score.classList.remove("student-editable"); + + // if there's a letter grade, remove it - it might be inaccurate + if (score.parentElement && score.parentElement.parentElement && score.parentElement.parentElement.tagName.toUpperCase() === "SPAN" && score.parentElement.parentElement.classList.contains("awarded-grade") && /^[A-DF] /.test(score.parentElement.parentElement.textContent)) { + // note use of childNodes, it's not its own element + score.parentElement.parentElement.childNodes[0].remove(); + } + } + // update the assignment percentage + prepareScoredAssignmentGrade(gradeColContentWrap.querySelector(".injected-assignment-percent"), userScore, userMax); + if (!gradeColContentWrap.querySelector(".modified-score-percent-warning")) { + //gradeColContentWrap.getElementsByClassName("injected-assignment-percent")[0].style.paddingRight = "0"; + gradeColContentWrap.appendChild(generateScoreModifyWarning()); + gradesModified = true; + } + + // don't alter totals for dropped assignment + if (assignment.classList.contains("dropped")) { + if (finishedCallback) { + finishedCallback(); + } + + return true; + } + + recalculateCategoryScore(catRow, deltaPoints, deltaMax, true, courseId); + recalculatePeriodScore(perRow, deltaPoints, deltaMax, true, courseId); + + if (finishedCallback) { + finishedCallback(); + } + + return true; + }; + let cleanupFunc = function () { + editElem.removeEventListener("blur", blurFunc); + editElem.removeEventListener("keydown", keyFunc); + }; + let keyFunc = function (event) { + if (event.which == 13 || event.keyCode == 13) { + editElem.blur(); + return false; + } + return true; + }; + let blurFunc = function (event) { + if (submitFunc()) { + cleanupFunc(); + var sel = window.getSelection ? window.getSelection() : document.selection; + if (sel) { + if (sel.removeAllRanges) { + sel.removeAllRanges(); + } else if (sel.empty) { + sel.empty(); + } + } + } else { + editElem.focus(); + } + return false; + } + editElem.addEventListener("blur", blurFunc); + editElem.addEventListener("keydown", keyFunc); + editElem.focus(); + document.execCommand('selectAll', false, null); + }; + } +})().then(() => { + Logger.log("Retrieving (" + fetchQueue.length + ") nonentered assignments info...") + processNonenteredAssignments(); +}).catch(reason => { + Logger.error("Error running grades page modification script: ", reason); +}); + +function createAddAssignmentElement(category) { + let addAssignmentThing = createElement("tr", ["report-row", "item-row", "last-row-of-tier", "grade-add-indicator"]); + addAssignmentThing.dataset.parentId = category.dataset.id; + // to avoid a hugely annoying DOM construction + // edit indicator will be added later + // FIXME add little plus icon + addAssignmentThing.innerHTML = '
No comment
'; + addAssignmentThing.getElementsByClassName("title")[0].firstElementChild.addEventListener("click", function () { + if (event.target.contentEditable !== "true") { + addAssignmentThing.querySelector("img.grade-edit-indicator").click(); + } + else { + document.execCommand("selectall", null, false); + } + }); + return addAssignmentThing; +} + +function processNonenteredAssignments() { + if (fetchQueue.length > 0) { + let [func, attempts] = fetchQueue.shift() + sleep = attempts > 0 + setTimeout(() => { + func().then(x => { + processNonenteredAssignments(); + }).catch(err => { + Logger.warn("Caught error: ", err); + Logger.log("Waiting 3 seconds to avoid rate limit"); + if (err && err.firstTryError && err.firstTryError.status === 403) { + attempts = 100; + } + if (attempts > 3) { + Logger.warn("Maximum attempts reached; aborting"); + } else { + fetchQueue.push([func, attempts + 1]) + } + processNonenteredAssignments(); + }); + }, sleep ? 3000 : 0); + } +} + +function roundDecimal(num, dec) { + let intPart = Math.floor(num); + let floatPart = num - intPart; + return intPart + (Math.round(floatPart * Math.pow(10, dec)) / Math.pow(10, dec)); +} + Logger.debug("Finished loading grades.js"); \ No newline at end of file diff --git a/js/home.js b/js/home.js index 333b2526..8f079944 100644 --- a/js/home.js +++ b/js/home.js @@ -38,7 +38,16 @@ function postFromBroadcast(broadcast) { createElement("span", ["visually-hidden"], { textContent: "posted to" }) ]), createElement("a", ["sExtlink-processed"], { textContent: "Schoology Plus Announcements" }), - createElement("span", ["splus-broadcast-close"], { textContent: "×", title: "Dismiss notification", onclick: () => trackEvent(`broadcast${broadcast.id}`, "close", "Broadcast") }), + createElement("span", ["splus-broadcast-close"], { + textContent: "×", title: "Dismiss notification", onclick: () => trackEvent("button_click", { + id: "close", + context: "Broadcast", + value: broadcast.id, + legacyTarget: `broadcast${broadcast.id}`, + legacyAction: "close", + legacyLabel: "Broadcast" + }) + }), createElement("span", ["update-body", "s-rte"], {}, [ createElement("p", ["no-margins"], {}, [ createElement("strong", ["splus-broadcast-title"], { innerHTML: broadcast.title }) @@ -73,7 +82,7 @@ function postFromBroadcast(broadcast) { function dismissNotification(event) { let id = event.target.dataset.broadcastId; - + let unreadBroadcasts = Setting.getValue("unreadBroadcasts"); unreadBroadcasts.splice(unreadBroadcasts.findIndex(x => x.id == id), 1); Setting.setValue("unreadBroadcasts", unreadBroadcasts); @@ -94,10 +103,10 @@ if (homeFeedContainer && Setting.getValue("broadcasts") !== "disabled") { (async function () { try { let onlineBroadcasts = await (await fetch("https://schoologypl.us/alert.json")).json(); - + let readBroadcasts = localStorage.getItem("splus-readBroadcasts"); readBroadcasts = readBroadcasts === null ? [] : JSON.parse(readBroadcasts); - + saveBroadcasts(onlineBroadcasts.filter(b => !readBroadcasts.includes(b.id))); } catch (err) { // Ignore diff --git a/js/preload.js b/js/preload.js index 4e9da86b..c9de8723 100644 --- a/js/preload.js +++ b/js/preload.js @@ -214,7 +214,7 @@ function createElement(tag, classList, properties, children) { * @param {(e: Event)=>void} callback A function to be called when the button is clicked */ function createButton(id, text, callback) { - return createElement("span", ["submit-span-wrapper", "splus-modal-button"], { onclick: callback }, [createElement("input", ["form-submit", "splus-track-clicks"], { type: "button", value: text, id: id, dataset: { splusTrackingLabel: "S+ Button" } })]); + return createElement("span", ["submit-span-wrapper", "splus-modal-button"], { onclick: callback }, [createElement("input", ["form-submit", "splus-track-clicks"], { type: "button", value: text, id: id, dataset: { splusTrackingContext: "S+ Button" } })]); } /** @@ -979,7 +979,16 @@ Setting.saveModified = function (modifiedValues, updateButtonText = true, callba if (!setting) { continue; } - trackEvent(settingName, `set value: ${newValues[settingName]}`, "Setting"); + + trackEvent("update_setting", { + id: settingName, + context: "Settings", + value: newValues[settingName], + legacyTarget: settingName, + legacyAction: `set value: ${newValues[settingName]}`, + legacyLabel: "Setting" + }); + if (!setting.getElement()) { continue; } @@ -1006,7 +1015,12 @@ Setting.saveModified = function (modifiedValues, updateButtonText = true, callba */ Setting.restoreDefaults = function () { if (confirm("Are you sure you want to delete all settings?\nTHIS CANNOT BE UNDONE")) { - trackEvent("restore-defaults", "restore default values", "Setting"); + trackEvent("reset_settings", { + context: "Settings", + legacyTarget: "restore-defaults", + legacyAction: "restore default values", + legacyLabel: "Setting" + }); for (let setting in __settings) { delete __storage[setting]; chrome.storage.sync.remove(setting); @@ -1020,7 +1034,14 @@ Setting.restoreDefaults = function () { * Exports settings to the clipboard in JSON format */ Setting.export = function () { - trackEvent("export-settings", "export settings", "Setting"); + trackEvent("button_click", { + id: "export-settings", + context: "Settings", + legacyTarget: "export-settings", + legacyAction: "export settings", + legacyLabel: "Setting" + }); + navigator.clipboard.writeText(JSON.stringify(__storage, null, 2)) .then(() => alert("Copied settings to clipboard!")) .catch(err => alert("Exporting settings failed!")); @@ -1030,7 +1051,13 @@ Setting.export = function () { * Import settings from clipboard in JSON format */ Setting.import = function () { - trackEvent("import-settings", "attempt import settings", "Setting"); + trackEvent("button_click", { + id: "import-settings-attempt", + context: "Settings", + legacyTarget: "import-settings", + legacyAction: "attempt import settings", + legacyLabel: "Setting" + }); if (confirm("Are you sure you want to import settings? Importing invalid or malformed settings will most likely break Schoology Plus.")) { let importedSettings = prompt("Please paste settings to import below:"); @@ -1042,7 +1069,13 @@ Setting.import = function () { } Setting.setValues(importedSettingsObj, () => { - trackEvent("import-settings", "successfully imported settings", "Setting"); + trackEvent("button_click", { + id: "import-settings-success", + context: "Settings", + legacyTarget: "import-settings", + legacyAction: "successfully imported settings", + legacyLabel: "Setting" + }); alert("Successfully imported settings. If Schoology Plus breaks, please restore defaults or reinstall. Reloading page.") location.reload(); }); diff --git a/js/theme-editor.js b/js/theme-editor.js index ddb8a40f..df1a4f5e 100644 --- a/js/theme-editor.js +++ b/js/theme-editor.js @@ -116,9 +116,17 @@ createPresetClassicTheme.addEventListener("click", e => editTheme("Schoology Plu var previewNavbar = document.getElementById("preview-navbar"); var previewLogo = document.getElementById("preview-logo"); var previewPage = document.getElementById("preview-page"); +var lastSelectedTemplate = null; var modernEnable = document.getElementById("modern-enable"); -modernEnable.addEventListener("click", e => trackEvent("modern-enable", modernEnable.checked.toString(), "Theme Editor")); +modernEnable.addEventListener("click", e => trackEvent("update_setting", { + id: "modern-enable", + context: "Theme Editor", + value: modernEnable.checked.toString(), + legacyTarget: "modern-enable", + legacyAction: modernEnable.checked.toString(), + legacyLabel: "Theme Editor" +})); var modernWrapper = document.getElementById("modern-wrapper"); var modernBorderRadiusValue = document.getElementById("modern-border-radius-value"); var modernBorderSizeValue = document.getElementById("modern-border-size-value"); @@ -130,14 +138,26 @@ splusModalClose.addEventListener("click", e => { e.stopPropagation(); previewModal.classList.add("hidden"); previewPage.classList.remove("hidden"); - trackEvent("splus-modal-close", "click", "Theme Editor"); + trackEvent("button_click", { + id: "close-preview-modal", + context: "Theme Editor", + legacyTarget: "splus-modal-close", + legacyAction: "click", + legacyLabel: "Theme Editor" + }); }); var previewSPlusButton = document.getElementById("preview-splus-button"); previewSPlusButton.addEventListener("click", e => { e.stopPropagation(); previewModal.classList.toggle("hidden"); previewPage.classList.toggle("hidden"); - trackEvent("preview-splus-button", "click", "Theme Editor"); + trackEvent("button_click", { + id: "preview-splus-button", + context: "Theme Editor", + legacyTarget: "preview-splus-button", + legacyAction: "click", + legacyLabel: "Theme Editor" + }); }); class Modal { @@ -170,7 +190,14 @@ class Modal { Modal.BUTTONS_CONTAINER.appendChild(createElement("a", ["modal-close", "waves-effect", "waves-dark", "btn-flat"], { textContent: b, onclick: e => { - trackEvent("Modal Button", b, "Theme Editor"); + trackEvent("button_click", { + id: "modal-button", + context: "Theme Editor", + value: b, + legacyTarget: "Modal Button", + legacyAction: b, + legacyLabel: "Theme Editor" + }); selected = b; } })); @@ -436,7 +463,7 @@ function renderTheme(t) { themeCustomLogo.click(); break; } - $(themeHue).slider("value", t.color.hue); + $(themeHue).slider("value", t.color.hue || 200); colorRainbowHueAnimate.checked = false; colorRainbowHueSpeed.value = 50; $(colorRainbowHueRange).roundSlider("setValue", "0,359"); @@ -901,6 +928,8 @@ function updateOutput() { } setCSSVariable("background-url", `url(${themeLogo.value})`); }, () => errors.push("Logo URL does not point to a valid image")); + } else { + errors.push("No logo URL is specified"); } } @@ -982,12 +1011,57 @@ function trySaveTheme(apply = false) { * If the querystring parameter `theme` exists, it will rename the theme with that value * @param {boolean} [apply=false] If true, applies the theme and returns to defaultDomain */ -function saveTheme(apply = false) { +function saveTheme(apply = false, imported = false) { if (errors.length > 0) throw new Error("Please fix all errors before saving the theme:\n" + errors.join("\n")); let t = JSON.parse(output.value); if (origThemeName && t.name != origThemeName) { ConfirmModal.open("Rename Theme?", `Are you sure you want to rename "${origThemeName}" to "${t.name}"?`, ["Rename", "Cancel"], b => b === "Rename" && doSave(t)); - } else { + } else { + trackEvent("perform_action", { + id: "color_type", + context: "New Theme Created", + value: Object.keys(t.color).find(k => k !== "modern"), + legacyTarget: "color_type", + legacyAction: Object.keys(t.color).find(k => k !== "modern"), + legacyLabel: "New Theme Created" + }); + + trackEvent("perform_action", { + id: "logo_type", + context: "New Theme Created", + value: t.logo.preset || "custom", + legacyTarget: "logo_type", + legacyAction: t.logo.preset || "custom", + legacyLabel: "New Theme Created" + }); + + trackEvent("perform_action", { + id: "cursor_type", + context: "New Theme Created", + value: t.cursor ? "primary" : "none", + legacyTarget: "cursor_type", + legacyAction: t.cursor ? "primary" : "none", + legacyLabel: "New Theme Created" + }); + + trackEvent("perform_action", { + id: "modern_enabled", + context: "New Theme Created", + value: String(!!t.color.modern), + legacyTarget: "modern_enabled", + legacyAction: String(!!t.color.modern), + legacyLabel: "New Theme Created" + }); + + trackEvent("perform_action", { + id: "preset", + context: "New Theme Created", + value: imported ? "Imported" : lastSelectedTemplate, + legacyTarget: "preset", + legacyAction: imported ? "Imported" : lastSelectedTemplate, + legacyLabel: "New Theme Created" + }); + doSave(t); } @@ -998,8 +1072,8 @@ function saveTheme(apply = false) { chrome.storage.sync.set({ themes: themes }, () => { if (chrome.runtime.lastError) { if (chrome.runtime.lastError.message.includes("QUOTA_BYTES_PER_ITEM")) { - alert("No space remaining to save theme. Please delete another theme or make this theme smaller in order to save."); - throw new Error("No space remaining to save theme. Please delete another theme or make this theme smaller in order to save."); + alert("No space remaining to save theme. Please delete another theme or make this theme smaller in order to save. Most commonly themes are too large if they have too many custom icons."); + throw new Error("No space remaining to save theme. Please delete another theme or make this theme smaller in order to save. Most commonly themes are too large if they have too many custom icons."); } } ConfirmModal.open("Theme saved successfully", "", ["OK"], () => { @@ -1113,7 +1187,15 @@ function applyTheme(t) { function deleteTheme(name) { ConfirmModal.open("Delete Theme?", `Are you sure you want to delete the theme "${name}"?\nThe page will reload when the theme is deleted.`, ["Delete", "Cancel"], b => { if (b === "Delete") { - trackEvent(`Theme: ${name}`, "delete", "Theme List"); + trackEvent("button_click", { + id: "delete-theme", + context: "Theme List", + value: name, + legacyTarget: `Theme: ${name}`, + legacyAction: "delete", + legacyLabel: "Theme List" + }); + chrome.storage.sync.get(["theme", "themes"], s => { chrome.storage.sync.set({ theme: s.theme == name ? null : s.theme, themes: s.themes.filter(x => x.name != name) }, () => window.location.reload()); }); @@ -1127,7 +1209,15 @@ function deleteTheme(name) { * @param {string} [name] The theme to edit */ function editTheme(name, replaceName = undefined) { - trackEvent(`Theme: ${name}`, "edit", "Theme List"); + lastSelectedTemplate = name; + trackEvent("button_click", { + id: "edit-theme", + context: "Theme List", + value: name, + legacyTarget: `Theme: ${name}`, + legacyAction: "edit", + legacyLabel: "Theme List" + }); clearInterval(rainbowInterval); themesListSection.classList.add("hidden"); themeEditorSection.classList.remove("hidden"); @@ -1153,7 +1243,7 @@ function importTheme() { try { let j = JSON.parse(text); importAndRender(j); - saveTheme(); + saveTheme(false, true); } catch { ConfirmModal.open("Error Importing Theme", errors.length > 0 ? errors.join() : "Please provide a valid JSON string", ["OK"]); @@ -1222,7 +1312,13 @@ function generateRainbowFunction(theme) { } function addIcon() { - trackEvent("new-icon", "click", "Theme Editor"); + trackEvent("button_click", { + id: "add-theme-icon", + context: "Theme Editor", + legacyTarget: "new-icon", + legacyAction: "click", + legacyLabel: "Theme Editor" + }); let template = `arrow_downward arrow_upwarddelete`; let tr = document.createElement("tr"); tr.innerHTML = template; @@ -1276,7 +1372,14 @@ function uploadAndPaste(pasteEvent) { } else { document.execCommand('paste', false, link); } - trackEvent("icon-image", "paste", "Theme Editor"); + trackEvent("perform_action", { + id: "paste", + context: "Theme Editor", + value: "icon-image", + legacyTarget: "icon-image", + legacyAction: "paste", + legacyLabel: "Theme Editor" + }); preview.src = link; pasteEvent.target.dataset.text = pasteEvent.target.dataset.originalText; pasteEvent.target.dataset.originalText = ""; @@ -1328,7 +1431,13 @@ function moveDown(e) { } function deleteIcon(e) { - trackEvent("delete-icon-button", "click", "Theme Editor"); + trackEvent("button_click", { + id: "delete-theme-icon", + context: "Theme Editor", + legacyTarget: "delete-icon-button", + legacyAction: "click", + legacyLabel: "Theme Editor" + }); let target = e.target; while (target.tagName != "TR") target = target.parentElement; M.Tooltip.getInstance(target.querySelector(".delete-icon-button")).destroy(); @@ -1365,7 +1474,14 @@ function iconPreview(e) { } function copyThemeToClipboard(themeName) { - trackEvent(`Theme: ${themeName}`, "copy", "Theme List"); + trackEvent("button_click", { + id: "copy-theme", + context: "Theme List", + value: themeName, + legacyTarget: `Theme: ${themeName}`, + legacyAction: "copy", + legacyLabel: "Theme List" + }); let text = JSON.stringify(allThemes[themeName]); var copyFrom = $('