// ==UserScript==
// @name Education Unlock Calculator
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @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")
}
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("afterEnd", `
<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">
<i class="fa fa-cog" aria-hidden="true"></i>
</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()
});
}
//})
})();