Education Unlock Calculator

Calculates the total time needed to unlock an education.

// ==UserScript==
// @name         Education Unlock Calculator
// @namespace    http://tampermonkey.net/
// @version      1.0.3
// @description  Calculates the total time needed to unlock an education.
// @author       NichtGersti [3380912]
// @license      MIT
// @run-at       document-end
// @match        https://www.torn.com/page.php?sid=education*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com

// ==/UserScript==

//MINIMAL API KEY
//TODO: extensive testing, especially for no course joined

(async function() { //window.addEventListener("load", async () => {
    'use strict';
    let root = document.querySelector("#education-root");

    let prefix = "education-unlock-time-calculator";
    let icon = `<i class="fm-extension-icon"></i>`;
    let mainText = `The userscript "Education Unlock Time Calculator" is running.`;
    let settings = {
        "api-key": localStorage.getItem("education-unlock-time-calculator-api-key") ?? '###PDA-APIKEY###', //Minimal access or above!
    };

    let settingsConfig = [
        {
            id: "api-key",
            label: "API Key (Minimal Access):",
            type: "password",
            value: settings["api-key"],
            validate: (input) => (input.length == 16),
        },
    ];

    injectBanner(root, prefix, icon, mainText, settingsConfig, settings, saveSettingsCallback);


    const userApiRes = await (fetchTornApi(settings["api-key"],"user/?selections=education,perks"));
    const userEducations = userApiRes.education;
    const userPerks = {
        meritPerk: (Number.parseInt(userApiRes.merit_perks.filter(perk => perk.match(/\+ \d+% education length reduction/))[0]?.match(/\d+/)[0]) / 100) || 0,
        stockPerk: userApiRes.stock_perks.includes("+ 10% course time reduction (WSU)") ? 0.1 : 0,
        jobPerk: userApiRes.job_perks.includes("+ 10% course time reduction") ? 0.1 : 0,
    };
    const timeReduction = 1 - objectSum(userPerks);

    const tornEducationsApiRes = await (fetchTornApi(settings["api-key"],"torn/?selections=education"));
    
    /* For when the API gets fixed.
    const tornEducations = tornEducationsApiRes.education.map((category) => {
        return {
            id: category.id,
            courses: category.courses.map(course => {
                const unlockDuration = category.courses
                .filter(filterCourse =>
                        course.prerequisites.courses.includes(filterCourse.id)
                        && !userEducations.complete.includes(filterCourse.id)
                        && !(userEducations.current
                             && (userEducations.current.id == filterCourse.id))
                       )
                .reduce((acc, filteredCourse) => acc + filteredCourse.duration, 0);
                return {
                    id: course.id,
                    duration: course.duration,
                    prerequisites: course.prerequisites.courses,
                    unlockDuration: unlockDuration,
                };
            }),
        };
    });
    */

    /* Weird API version */
    const adjustedEducations = tornEducationsApiRes.education.map((category) => {
        const introductionId = category.courses[0].id
        const courseCount = category.courses.length
        return {
            id: category.id,
            courses: [
                {
                    id: introductionId,
                    duration: category.courses[0].duration,
                    prerequisites: category.courses[0].prerequisites.courses
                },
                ...category.courses.slice(1,-1).map(course => {
                    return {
                        id: course.id,
                        duration: course.duration,
                        prerequisites: [introductionId, ...course.prerequisites.courses]
                    };
                }),
                {
                    id: category.courses[courseCount-1].id,
                    duration: category.courses[courseCount-1].duration,
                    prerequisites: category.courses[courseCount-1].prerequisites.courses
                },
            ].map(course => {
                const unlockDuration = category.courses
                .filter(filterCourse =>
                        course.prerequisites.includes(filterCourse.id)
                        && !userEducations.complete.includes(filterCourse.id)
                        && !(userEducations.current
                             && (userEducations.current.id == filterCourse.id))
                       )
                .reduce((acc, filteredCourse) => acc + filteredCourse.duration, 0)
                return {
                    id: course.id,
                    duration: course.duration,
                    prerequisites: course.prerequisites,
                    unlockDuration: unlockDuration,
                };
            }),
        };
    });


    navigation.addEventListener('navigate', inject);
    inject();

    function fetchTornApi(key, selections) {
        return fetch(`https://api.torn.com/v2/${selections}&key=${key}`).then( response => {
            if (response.ok) {
                return response.json();
            }
            throw new Error('Something went wrong');
        })
            .then( result => {
            if (result.error) {
                switch (result.error.code){
                    case 2:
                        localStorage.setItem("nichtgersti-flying-oc-alert-api", null);
                        console.error("Incorrect Api Key:", result);
                        throw new Error("Incorrect Api Key:");
                    case 9:
                        console.warn("The API is temporarily disabled, please try again later");
                        throw new Error("The API is temporarily disabled, please try again later");
                    default:
                        console.error("Error:", result.error.error);
                        throw new Error(result.error.error);
                        return;
                }
            }
            return result;
        })
    }

    function inject() {
        setTimeout(() => {
            try {
                const href = document.location.href;
                const categoryId = href.match(/category=\d+/)[0].match(/\d+/)[0];
                const courseId = href.match(/course=\d+/)[0].match(/\d+/)[0];
                let unlockDuration = adjustedEducations.filter((category => category.id == categoryId))[0].courses.filter(course => course.id == courseId)[0].unlockDuration;
                let perkUnlockDuration = unlockDuration * timeReduction;
                if (unlockDuration <= 0) return;
                Array.from(document.querySelectorAll("#education-root .categories___AfufT .mainContent___FB5pl .label___H8zzk"))
                    .filter(node => node.textContent == "Parameters:")[0]
                    .nextSibling
                    .insertAdjacentHTML("beforeend", `
                    <li class="listItem___JP33F">
                        Time left on prerequisites:
                        ${timeReduction < 1 ? '<span class="originParam___j4nxB">' + secondsToString(unlockDuration) + '</span>' : ""}
                        ${secondsToString(perkUnlockDuration)}
                    </li>
                `);
            } catch {};
        }, 100)
    }

    function saveSettingsCallback() {
        localStorage.setItem("education-unlock-time-calculator-api-key", settings["api-key"]);
    }

    function secondsToString(totalSeconds) {
        let days = Math.floor(totalSeconds / 86400);
        let hours = Math.floor(totalSeconds / 3600) % 24;
        let minutes = Math.floor(totalSeconds / 60) % 60;
        let seconds = totalSeconds % 60;

        let timerString = "";
        if (totalSeconds > 86400) timerString += `${days}d `;
        if (totalSeconds > 3600) timerString += `${hours}h `;
        if (totalSeconds > 60) timerString += `${minutes}m`;
        return timerString;
    }

    function sum(array) {
        return array.reduce((a,b) => a+b, 0);
    }

    function objectSum(obj) {
        return sum(Object.values(obj));
    }

    function injectBanner(root, prefix, icon, mainText, settingsConfig, settings, saveSettingsCallback) {
        let wrapper = root.querySelector(".wrapper");
        if (!wrapper) {
            const template = document.createElement('template');
            template.innerHTML = `
                <div aria-live="polite">
                    <div class="wrapper" role="alert" aria-live="polite">
                    </div>
                    <hr class="page-head-delimiter m-top10 m-bottom10">
                </div>
            `;
            wrapper = template.content.firstElementChild.firstElementChild;
            root.firstChild.append(template.content.firstElementChild);
        }

        function singleSettingHTML(config) {
            return `
                <label for="${config.id}">${config.label}</label>
                <input type="${config.type}" id="${config.id}" name="${config.id}" value="${config.value}">
                <br>
                <br>
            `;
        }

        wrapper.insertAdjacentHTML("afterBegin", `
            <div class="info-msg-cont border-round m-top10 blue">
                <div class="info-msg border-round messageWrap___phpSP">
                    ${icon}
                    <div class="delimiter">
                        <div class="msg right-round messageContent___LhCmx">
                            <div style="display:flex;justify-content: space-between;align-items: center;">
                                <span style="display:inline-block;vertical-align:middle">
                                    ${mainText}
                                </span>
                                <div id="${prefix}-settings-button" style="display:inline-block;float:right">
                                    <svg xmlns="http://www.w3.org/2000/svg" class="default___XXAGt" filter="" fill="#fff" stroke="transparent" stroke-width="0" width="28" height="28" viewBox="-6 -4 28 28"><path data-name="Path 7-4" d="M16,5.67a8.47,8.47,0,0,0-.66-1.59,2.57,2.57,0,0,1-2.58-.84A2.48,2.48,0,0,1,12.11.66,8.47,8.47,0,0,0,10.52,0,2.83,2.83,0,0,1,6.71,1.23,2.81,2.81,0,0,1,5.48,0,8.47,8.47,0,0,0,3.89.66a2.48,2.48,0,0,1-.65,2.58,2.57,2.57,0,0,1-2.58.84A8.47,8.47,0,0,0,0,5.67,2.75,2.75,0,0,1,1.54,8,3,3,0,0,1,0,10.52a8.47,8.47,0,0,0,.66,1.59,2.59,2.59,0,0,1,3.23,1.74,2.52,2.52,0,0,1,0,1.49A7.85,7.85,0,0,0,5.48,16a2.83,2.83,0,0,1,5,0,8.47,8.47,0,0,0,1.59-.66,2.48,2.48,0,0,1,.65-2.58,2.57,2.57,0,0,1,2.58-.84A7.85,7.85,0,0,0,16,10.33,2.75,2.75,0,0,1,14.46,8,2.75,2.75,0,0,1,16,5.67ZM8,11.48A3.48,3.48,0,1,1,11.48,8,3.48,3.48,0,0,1,8,11.48Z"></path></svg>
                                </div>
                            </div>
                            <div id="${prefix}-settings" hidden>
                                <hr style="margin-top:10px;margin-bottom:10px">
                                <div style="display:flex;justify-content: space-between;align-items: center;">
                                    <div style="display:inline-block;vertical-align:middle">
                                        ${settingsConfig.reduce((total, config) => total + singleSettingHTML(config), "")}
                                    </div>
                                    <div id="${prefix}-save-settings-button" class="btn torn-btn btn-action-tab btn-dark-bg" style="display:inline-block;float:right">
                                        Save
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        `);

        const settingsButton = document.querySelector(`#${prefix}-settings-button`);
        settingsButton.addEventListener("click", () => {
            const settingsNode = document.querySelector(`#${prefix}-settings`);
            settingsNode.hidden = !settingsNode.hidden;
        });
        const saveSettingsButton = document.querySelector(`#${prefix}-save-settings-button`);
        saveSettingsButton.addEventListener("click", () => {
            settingsConfig.forEach(setting => {
                const input = document.querySelector("#" + setting.id)
                if (!setting.validate || setting.validate(input.value)) Object.defineProperty(settings, setting.id, {value: input.value, writable: true, enumerable: true})
                else input.value = settings[setting.id] || ""
            });
            saveSettingsCallback();
        });
    }

//});
})();