Last active
September 21, 2023 17:46
-
-
Save luttje/752ef2b934e0b40bb82dfab34bddf0cd to your computer and use it in GitHub Desktop.
MyX Enhancer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name MyX Schedule Enhancer | |
// @namespace http://lutt.online/myx-enhancer | |
// @version 0.2 | |
// @description Adds useful features to MyX, by injecting ourselves into the page | |
// @author Lutt.online | |
// @match https://curio.myx.nl/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=myx.nl | |
// @run-at document-body | |
// @grant none | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
/** | |
* Common Utilities | |
*/ | |
// Map to store listeners for specific URLs | |
const urlListeners = new Map(); | |
// Store a reference to the original XMLHttpRequest object | |
const orig_XMLHttpRequest = window.XMLHttpRequest; | |
// Create a new constructor for our overridden XMLHttpRequest | |
window.XMLHttpRequest = function () { | |
// Create a new instance of the original XMLHttpRequest | |
const xhr = new orig_XMLHttpRequest(); | |
// Override the open method to capture request details | |
xhr._open = xhr.open; | |
xhr.open = function (requestMethod, requestUrl, isAsync, p) { | |
// console.log(`π Request: ${requestMethod} ${requestUrl}`); | |
return this._open(requestMethod, requestUrl, true, p); // Force async (or MyX errors) | |
}; | |
// Override the send method to log data before sending | |
xhr._send = xhr.send; | |
xhr.send = function (data) { | |
// console.log('π Sending data:', data); // Log the data being sent | |
return this._send(data); // Call the original send method | |
}; | |
// Override setRequestHeader to store the authorization token | |
xhr._setRequestHeader = xhr.setRequestHeader; | |
xhr.setRequestHeader = function (header, value) { | |
// console.log('π Setting header:', header, value); // Log the header being set | |
if (header === 'Authorization') { | |
window._bearerToken = value; | |
} | |
return this._setRequestHeader(header, value); // Call the original setRequestHeader method | |
}; | |
// Add an event listener for the load event to log the response | |
xhr.addEventListener('load', function () { | |
const responseURL = this.responseURL; | |
// console.log('π Response URL:', responseURL); | |
// console.log('π Response:', this.responseText); | |
// Check if there's a listener for this URL | |
if (urlListeners.has(responseURL)) { | |
const callback = urlListeners.get(responseURL); | |
callback(this.responseText); // Execute the callback function | |
} | |
}); | |
// Return the overridden instance | |
return xhr; | |
}; | |
// Spy when a specific route happens in XHR and execute a callback | |
function spyOnRoute(URL, callback) { | |
// Add the listener to the map | |
urlListeners.set(URL, callback); | |
} | |
// Show modals with a custom title and message | |
function showModal(title, message, onConfirm, onClose) { | |
//<modal-container role="dialog" tabindex="-1" class="modal fade show" style="display: block;" aria-modal="true"><div tabindex="0" class="cdk-visually-hidden cdk-focus-trap-anchor" aria-hidden="true"></div><div role="document" focustrap="" class="modal-align-center modal-dialog modal-sm"><div class="modal-content"><myx-message-modal _nghost-owi-c31=""><myx-content-modal _ngcontent-owi-c31="" _nghost-owi-c30=""><myx-modal _ngcontent-owi-c30="" _nghost-owi-c29=""><!----><div _ngcontent-owi-c29="" class="modal-body"><div _ngcontent-owi-c30="" class="body d-flex flex-column align-items-center pt-5 pb-0 px-5 confirm"><span _ngcontent-owi-c30="" class="myx-icon mb-3 myx-i-help-circle"></span><h2 _ngcontent-owi-c30="" class="text-capitalize-first text-title text-title-large text-center mb-4 ng-star-inserted"> Rooster verwijderen </h2><!----><p _ngcontent-owi-c31="" class="text-capitalize-first text-center text-pre-wrap"> Weet je zeker dat je dit rooster wilt verwijderen? </p><!----></div></div><div _ngcontent-owi-c29="" class="modal-footer"><!----><div _ngcontent-owi-c30="" class="footer p-5 m-0 d-flex justify-content-center flex-grow-1 ng-star-inserted"><button _ngcontent-owi-c30="" class="btn btn-bare-primary text-uppercase"> annuleer </button><button _ngcontent-owi-c30="" class="btn btn-primary text-uppercase">ok</button></div><!----><!----></div></myx-modal></myx-content-modal></myx-message-modal></div></div><div tabindex="0" class="cdk-visually-hidden cdk-focus-trap-anchor" aria-hidden="true"></div></modal-container> | |
const modalContainer = document.createElement('modal-container'); | |
modalContainer.setAttribute('role', 'dialog'); | |
modalContainer.setAttribute('tabindex', '-1'); | |
modalContainer.className = 'modal fade show'; | |
modalContainer.style.display = 'block'; | |
modalContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'; | |
modalContainer.style.zIndex = '99999'; | |
let buttons = ''; | |
if (onClose !== false) | |
buttons += '<button class="btn btn-bare-primary text-uppercase">annuleer</button>'; | |
if (onConfirm !== false) | |
buttons += '<button class="btn btn-primary text-uppercase">ok</button>'; | |
if (onClose === false && onConfirm === false) { | |
// Show spinning loading svg on confirm button | |
buttons = ` | |
<style> | |
.myx-loader { | |
width: 48px; | |
height: 48px; | |
border: 5px solid rgb(251, 223, 0); | |
border-bottom-color: transparent; | |
border-radius: 50%; | |
display: inline-block; | |
box-sizing: border-box; | |
animation: rotation 1s linear infinite; | |
margin-top: -2em; | |
} | |
@keyframes rotation { | |
0% { | |
transform: rotate(0deg); | |
} | |
100% { | |
transform: rotate(360deg); | |
} | |
} | |
</style> | |
<span class="myx-loader"></span> | |
`; | |
} | |
modalContainer.innerHTML = ` | |
<div tabindex="0" class="cdk-visually-hidden cdk-focus-trap-anchor" aria-hidden="true"></div> | |
<div role="document" focustrap="" class="modal-align-center modal-dialog modal-sm"> | |
<div class="modal-content"> | |
<myx-message-modal> | |
<myx-content-modal> | |
<myx-modal> | |
<div class="modal-body"> | |
<div class="body d-flex flex-column align-items-center pt-5 pb-0 px-5 confirm"> | |
<span class="myx-icon mb-3 myx-i-help-circle"></span> | |
<h2 class="text-capitalize-first text-title text-title-large text-center mb-4 ng-star-inserted">${title}</h2> | |
<p class="text-capitalize-first text-center text-pre-wrap">${message}</p> | |
</div> | |
</div> | |
<div class="modal-footer"> | |
<div class="footer p-5 m-0 d-flex justify-content-center flex-grow-1 ng-star-inserted"> | |
${buttons} | |
</div> | |
</div> | |
</myx-modal> | |
</myx-content-modal> | |
</myx-message-modal> | |
</div> | |
</div> | |
<div tabindex="0" class="cdk-visually-hidden cdk-focus-trap-anchor" aria-hidden="true"></div> | |
`; | |
document.body.appendChild(modalContainer); | |
if (onClose !== false) { | |
const cancelButton = modalContainer.querySelector('button.btn-bare-primary'); | |
cancelButton.addEventListener('click', () => { | |
modalContainer.remove(); | |
if (onClose) onClose(); | |
}); | |
} | |
if (onConfirm !== false) { | |
const confirmButton = modalContainer.querySelector('button.btn-primary'); | |
confirmButton.addEventListener('click', () => { | |
modalContainer.remove(); | |
if (onConfirm) onConfirm(); | |
}); | |
} | |
return modalContainer; | |
} | |
// Find settings changes | |
spyOnRoute('https://curio.myx.nl/api/Settings', (responseText) => { | |
if (!responseText) return; | |
const settings = JSON.parse(responseText); | |
window.__myxSettings = settings.result; | |
window.__myxRosterPresetRows = settings.result.schedules; | |
console.log('π Roster Preset Rows:', window.__myxRosterPresetRows); | |
addClearSchedulesButtonOnce(); | |
}); | |
// Finds and stores all Classrooms for later use | |
spyOnRoute('https://curio.myx.nl/api/Attendee/Type/Classroom', (responseText) => { | |
if (!responseText) return; | |
const classrooms = JSON.parse(responseText); | |
window.__myxClassrooms = classrooms.result; | |
console.log('π Classrooms:', classrooms.result); | |
}); | |
// Groups | |
spyOnRoute('https://curio.myx.nl/api/Attendee/Type/Group', (responseText) => { | |
if (!responseText) return; | |
const groups = JSON.parse(responseText); | |
window.__myxGroups = groups.result; | |
console.log('π Groups:', groups.result); | |
}); | |
// Teachers | |
spyOnRoute('https://curio.myx.nl/api/Attendee/Type/Teacher', (responseText) => { | |
if (!responseText) return; | |
const teachers = JSON.parse(responseText); | |
window.__myxTeachers = teachers.result; | |
console.log('π Teachers:', teachers.result); | |
}); | |
function getClassroomByCode(code) { | |
if (!window.__myxClassrooms) return null; | |
return window.__myxClassrooms.find((classroom) => classroom.code === code); | |
} | |
function getGroupByCode(code) { | |
if (!window.__myxGroups) return null; | |
return window.__myxGroups.find((group) => group.code === code); | |
} | |
function getTeacherByCode(code) { | |
if (!window.__myxTeachers) return null; | |
return window.__myxTeachers.find((teacher) => teacher.code === code); | |
} | |
function getAnyByCode(code) { | |
return getGroupByCode(code) || getTeacherByCode(code) || getClassroomByCode(code); | |
} | |
function getAnyById(id) { | |
return window.__myxClassrooms.find((classroom) => classroom.id === id) || | |
window.__myxGroups.find((group) => group.id === id) || | |
window.__myxTeachers.find((teacher) => teacher.id === id); | |
} | |
function toggleScheduleAsFavorite(schedule) { | |
const favorites = JSON.parse(localStorage.getItem('myx-favorites')) || []; | |
let modalTitle = ''; | |
let modalMessage = ''; | |
if (favorites.includes(schedule.id)) { | |
favorites.splice(favorites.indexOf(schedule.id), 1); | |
modalTitle = 'Roosterselectie verwijderd uit favorieten'; | |
modalMessage = 'Het rooster is verwijderd uit je favorieten. Het zal verwijderd worden als je de roosterselectie leegt.'; | |
} else { | |
favorites.push(schedule.id); | |
modalTitle = 'Roosterselectie toegevoegd aan favorieten'; | |
modalMessage = 'Het rooster is toegevoegd aan je favorieten. Het zal niet verwijderd worden als je de roosterselectie leegt.'; | |
} | |
localStorage.setItem('myx-favorites', JSON.stringify(favorites)); | |
showModal(modalTitle, modalMessage, undefined, false); | |
} | |
function getScheduleIsFavorite(schedule) { | |
const favorites = JSON.parse(localStorage.getItem('myx-favorites')) || []; | |
return favorites.includes(schedule.id); | |
} | |
/** | |
* Add Mark Schedule as Favorite button (so they never get removed) | |
*/ | |
// When a myx-list-context-menu is added as a distance child to myx-roster-selector-presets (the list of schedules), add a button to it mark the schedule as favorite | |
// Get the span > span inside the li that is the parent of the myx-list-context-menu to get the name of the schedule from it's innerText | |
new MutationObserver((mutations, observer) => { | |
mutations.forEach((mutation) => { | |
mutation.addedNodes.forEach((node) => { | |
if (node.nodeName !== 'MYX-LIST-CONTEXT-MENU') return; | |
const parent = node.parentElement; | |
// Sanity checks to make sure we're in the right place | |
if (!parent) return; | |
if (parent.nodeName !== 'LI') return; | |
if (!parent.parentElement) return; | |
if (!parent.parentElement.parentElement) return; | |
if (parent.parentElement.parentElement.nodeName !== 'MYX-ROSTER-SELECTOR-PRESETS') return; | |
// Skip the first in the UL, since that's just the 'Mijn rooster' item (which can't be removed anyway) | |
if (parent.parentElement.firstElementChild === parent) return; | |
setTimeout(() => { | |
// Check if the node still exists | |
if (!document.body.contains(node)) return; | |
if (!document.body.contains(parent)) return; | |
const scheduleCode = parent.querySelector('span > span').innerText; | |
const schedule = getAnyByCode(scheduleCode); | |
if (!schedule) { | |
console.error('π Schedule not found:', scheduleCode, ' (is this a renamed preset, or multiple classrooms?)'); | |
return; | |
} | |
const updateFavoriteIndicator = function () { | |
if (getScheduleIsFavorite(schedule)) { | |
const span = parent.querySelector(':scope > span'); | |
const star = document.createElement('i'); | |
star.className = 'myx-i-lock ml-4 -mr-2'; | |
span.insertBefore(star, span.firstElementChild); | |
} else if (parent.querySelector(':scope > span > i.myx-i-lock')) { | |
const span = parent.querySelector(':scope > span'); | |
span.removeChild(span.firstElementChild); | |
} | |
}; | |
// Add a button to the context menu to mark the schedule as favorite | |
const button = document.createElement('button'); | |
button.className = 'btn btn-light btn-semi-light border-0 ng-star-inserted'; | |
button.innerHTML = '<span class="text-uppercase">favoriet</span>'; | |
button.addEventListener('click', () => { | |
if (!schedule) { | |
showModal('Rooster niet gevonden', 'Het rooster kon niet worden gevonden. Ververs de pagina en probeer het opnieuw.'); | |
return; | |
} | |
toggleScheduleAsFavorite(schedule); | |
updateFavoriteIndicator(); | |
}); | |
node.querySelector('div.content-container').appendChild(button); | |
updateFavoriteIndicator(); | |
}, 1000); // Wait a bit for the schedule name to be added to the DOM (nice and unstable this, isn't it? π ) | |
}); | |
}); | |
}).observe(document.body, { childList: true, subtree: true }); | |
/** | |
* Add Clear Schedules Button | |
*/ | |
function addClearSchedulesButtonOnce() { | |
if (window.__myxHaveAddedClearSchedulesButton) return; | |
window.__myxHaveAddedClearSchedulesButton = true; | |
const targetParent = 'myx-roster-selector'; | |
const addClearRosterButton = function () { | |
// Add a button as the last element of the targetParent that logs all schedule id's and their names to console | |
const parent = document.querySelector(targetParent); | |
const button = document.createElement('button'); | |
button.className = 'btn btn-block text-uppercase btn-secondary btn-lg text-label text-label-button-secondary'; | |
button.innerText = 'Roosterselectie legen'; | |
button.addEventListener('click', () => { | |
const DEBUG_ONLY_ONE = false; | |
const DELAY = 500; | |
const rosterPresetKeysToRemove = Object.values(Object.keys(window.__myxRosterPresetRows)) | |
.filter((rosterPresetRowKey) => getScheduleIsFavorite(getAnyById(window.__myxRosterPresetRows[rosterPresetRowKey].ids[0])) === false); | |
if (rosterPresetKeysToRemove.length === 0) { | |
console.log('π No schedules to remove'); | |
showModal('Roosterselectie kan niet worden geleegd', 'Er zijn geen roosters om te verwijderen, of ze zijn allemaal als favoriet gemarkeerd.'); | |
return; | |
} | |
let loadingModal; | |
showModal('Roosterselectie legen', 'Het legen van de roosterselectie kan niet ongedaan worden gemaakt en kan even duren. Weet je zeker dat je dit wilt doen?', () => { | |
const removeRoster = function (scheduleId) { | |
const url = 'https://curio.myx.nl/api/Settings'; | |
const body = JSON.stringify([{ op: 'remove', path: `/schedules/${scheduleId}` }]); | |
const method = 'PATCH'; | |
const headers = { | |
'Content-Type': 'application/json', | |
Authorization: window._bearerToken, | |
}; | |
console.log('π Removing schedule:', scheduleId); | |
const processOrFinish = function () { | |
if (rosterPresetKeysToRemove.length === 0) { | |
console.log('π Finished removing schedules'); | |
loadingModal.remove(); | |
showModal('Roosterselectie geleegd', 'Alle roosters zijn verwijderd. De pagina wordt ververst om de wijzigingen te tonen.', () => { | |
window.location.reload(); | |
}); | |
return; | |
} else { | |
setTimeout(() => { | |
removeRoster(rosterPresetKeysToRemove.shift()); | |
}, DELAY); | |
} | |
}; | |
fetch(url, { method, headers, body }) | |
.then((response) => response.json()) | |
.then((data) => { | |
console.log('π Removed schedule:', scheduleId, data); | |
if (DEBUG_ONLY_ONE) return; | |
processOrFinish(); | |
}) | |
.catch((error) => { | |
console.error('π Error removing schedule:', scheduleId, error); | |
if (DEBUG_ONLY_ONE) return; | |
processOrFinish(); | |
}); | |
} | |
loadingModal = showModal('Roosterselectie aan het legen', 'De roosterselectie wordt geleegd. Dit kan even duren...', false, false); | |
removeRoster(rosterPresetKeysToRemove.shift()); | |
}); | |
}); | |
parent.appendChild(button); | |
} | |
// Add clear button once the parent has been found in the DOM | |
if (document.querySelector(targetParent)) { | |
addClearRosterButton(); | |
} else { | |
new MutationObserver((mutations, observer) => { | |
if (document.querySelector(targetParent)) { | |
addClearRosterButton(); | |
observer.disconnect(); | |
} | |
}).observe(document.body, { childList: true }); | |
} | |
}; | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment