Skip to content

Instantly share code, notes, and snippets.

@luttje
Last active September 21, 2023 17:46
Show Gist options
  • Save luttje/752ef2b934e0b40bb82dfab34bddf0cd to your computer and use it in GitHub Desktop.
Save luttje/752ef2b934e0b40bb82dfab34bddf0cd to your computer and use it in GitHub Desktop.
MyX Enhancer
// ==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