AP® Score Cheeser

Cheeses the AP® Exam scores on the College Board website by allowing modification.

// ==UserScript==
// @name         AP® Score Cheeser
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  Cheeses the AP® Exam scores on the College Board website by allowing modification.
// @author       Samathingamajig
// @match        https://apstudents.collegeboard.org/view-scores*
// @icon         https://www.google.com/s2/favicons?domain=collegeboard.org
// @grant        GM_setValue
// @grant        GM_getValue
// AP® is a trademark registered by the College Board, which is not affiliated with, and does not endorse, this product.
// ==/UserScript==

(async function () {
  "use strict";

  const sidebarBodies = {
    passing: `<div class="align-self-center"><p class="apscores-intro">Most U.S. colleges accept your score for credit and placement.</p><p><a class="cb-btn cb-btn-black cb-margin-sm-up-right-16 cb-margin-xs-bottom-8" href="https://apstudents.collegeboard.org/getting-credit-placement/search-policies/course/2">Find College Credit</a><a id="score-22-2" class="cb-padding-xs-top-4 display-xs-block-only cb-font-size-small" href="https://apstudents.collegeboard.org/about-ap-scores">About your <span class="sr-only">AP Biology</span> score</a></p></div>`,
    failing: `<div class="align-self-center"><p class="apscores-intro">You challenged yourself with college-level coursework.</p><p><a class="cb-btn cb-btn-black cb-margin-sm-up-right-16 cb-margin-xs-bottom-8" href="https://apcentral.collegeboard.org/about-ap/ap-a-glance/discover-benefits">Benefits of Taking AP</a><a id="score-22-2" class="cb-padding-xs-top-4 display-xs-block-only cb-font-size-small" href="https://apstudents.collegeboard.org/about-ap-scores">About your <span class="sr-only">AP Biology</span> score</a></p></div>`,
  };

  const clamp = (num, min, max) => Math.min(Math.max(num, min), max);

  // const snobserver = new MutationObserver((records) => {
  //   for (const record of records) {
  //     if (record.target.classList.contains("align-self-center")) {
  //       console.log(record.target);
  //     }
  //     // if (record.type == "characterData") {
  //     //   console.log(record.oldValue, target, record);
  //     // }
  //   }
  // });
  // snobserver.observe(document.body, {
  //   childList: true,
  //   subtree: true,
  //   characterData: true,
  //   attributes: true,
  //   attributeOldValue: true,
  // });

  // Grab all of the boxes that contain scores
  document.body.style.opacity = "0%"; // Hide the entire page until we can hide the scores themselves
  let ccontainers = [];
  let counter = 0;
  while ((ccontainers = document.querySelectorAll(".apscores-card-col-left.display-flex")).length == 0) {
    await new Promise((res) => setTimeout(res, 10)); // Wait 10 ms
    if (++counter >= 500) {
      document.body.style.opacity = "100%";
      return; // Exit program after 5 seconds of loading
    }
  } // Wait for page to load

  const changeScoreElement = (container, score) => {
    // Replace the old score class with the new one, this is the buildings and stuff you see below a score
    container.classList.forEach(
      (cls) =>
        /apscores-badge-score-\d/.test(cls) &&
        container.classList.replace(cls, `apscores-badge-score-${clamp(score, 1, 5)}`),
    );
    container.childNodes[1].nodeValue = score; // Set the text box that holds the score number
    container.parentNode.parentNode.nextSibling.innerHTML = sidebarBodies[score >= 3 ? "passing" : "failing"]; // Set the sidebar text
  };

  // await new Promise((res) => setTimeout(res, 1000));

  await new Promise((res) => setTimeout(res, 1000));
  // changeScoreElement(ccontainers[0].querySelector(".apscores-badge.apscores-badge-score"), 2);
  console.log(ccontainers[0].querySelector(".apscores-badge.apscores-badge-score"));

  const defaultScore = await GM.getValue("all");

  for (let i = 0; i < ccontainers.length; i++) {
    // Iterate through all the score boxes
    const ccontainer = ccontainers[i];
    const container = ccontainer.querySelector(".apscores-badge.apscores-badge-score");
    if (!container) continue; // Might've accidentally selected an award
    const courseName = ccontainer.parentNode.parentNode.querySelector("h4").innerText; // Grab the course name
    const scoreNode = container.childNodes[1]; // Grab the text box that holds the score number
    const savedScore = await GM.getValue(courseName);
    // if score is explicitly "null", then don't change the score, otherwise change it to the saved score, or the default score as backup, otherwise the current score
    const targetScore =
      savedScore === null ? Number(scoreNode.nodeValue) : savedScore ?? defaultScore ?? Number(scoreNode.nodeValue);
    changeScoreElement(container, targetScore); // Change the score to the target score

    const clickListener = async (e) => {
      e.stopPropagation();
      const shouldEdit = await new Promise((res) => {
        const timeout = setTimeout(() => res(true), 2000); // Wait for a 2 second hold
        const releaseListener = () => {
          res(false);
          clearTimeout(timeout);
          window.removeEventListener("mouseup", releaseListener, true);
        };
        window.addEventListener("mouseup", releaseListener, true);
      });
      if (!shouldEdit) {
        return; // If the user didn't hold down for 2 seconds, don't edit the score
      }

      const container = ccontainer.querySelector(".apscores-badge.apscores-badge-score"); // Have to do this again because reference gets messed
      const scoreNode = container.childNodes[1]; // Grab the text box that holds the score number

      let newScore = Number(scoreNode.nodeValue);
      let all = false;
      while (true) {
        // Keep asking for a new score until the user enters a valid one
        let newScoreTemp = prompt(
          `Enter a new score for ${courseName} (1-5)
        - If you type 'reset' it will reset this score,
        - 'all n' where n is 1-5 will set all scores,
        - 'reset all' or 'all reset' will reset all scores.`.replace(/^\s+/gm, ""),
          scoreNode.nodeValue,
        )
          ?.trim()
          .toLowerCase(); // Prompt the user for a new score
        if (newScoreTemp == null) return; // If the user cancelled, don't do anything
        if (newScoreTemp == "reset") {
          await GM.setValue(courseName, null);
          window.location.reload();
          return;
        }
        if (newScoreTemp == "reset all" || newScoreTemp == "all reset") {
          await Promise.all((await GM.listValues()).map((v) => v.startsWith("AP ") && GM.setValue(v, null)));
          await GM.deleteValue("all");
          window.location.reload();
          return;
        }

        if (!/^(?:all )?[+-]?\d+$/.test(newScoreTemp)) continue; // If the user didn't enter a number, ask again.
        if (newScoreTemp.startsWith("all ")) all = true;
        newScore = Number(newScoreTemp.replace("all ", "")); // Grab the new score
        await GM.setValue("all", newScore);
        break;
      } // Prompt the user for a new score

      if (all) {
        await Promise.all(
          Array.from(ccontainers).map(async (c) => {
            const container = c.querySelector(".apscores-badge.apscores-badge-score");
            if (!container) return;
            const courseName = c.parentNode.parentNode.querySelector("h4").innerText;
            changeScoreElement(container, newScore);
            await GM.setValue(courseName, newScore);
          }),
        );
      } else {
        changeScoreElement(container, newScore); // Change the score to the new score
        await GM.setValue(courseName, newScore); // Save the new score
      }
    };
    ccontainer.addEventListener("mousedown", clickListener); // Listen for a "mousedown" event on each container in this loop
  }

  let latestResize;
  window.addEventListener("resize", async (e) => {
    latestResize = e;
    ccontainers.forEach((c) => (c.parentNode.style.opacity = "0%"));
    await new Promise((res) => setTimeout(res, 100));
    if (latestResize !== e) return;

    await Promise.all(
      Array.from(ccontainers).map(async (c) => {
        const container = c.querySelector(".apscores-badge.apscores-badge-score");
        if (!container) return;
        const courseName = c.parentNode.parentNode.querySelector("h4").innerText;
        const scoreNode = container.childNodes[1];
        const targetScore = (await GM.getValue(courseName)) ?? Number(scoreNode.nodeValue);
        changeScoreElement(container, targetScore);
        return true;
      }),
    );
    ccontainers.forEach((c) => (c.parentNode.style.opacity = "100%"));
  });

  document.body.style.opacity = "100%"; // Reshow the page after hiding the scores and adding the click listeners
})();