NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name HBRS eva2 Timetable Cleaner // @version 1.3 // @description Clean up your HBRS eva2 timetable! // @author Temm // @updateURL https://openuserjs.org/meta/Temm/HBRS_eva2_Timetable_Cleaner.meta.js // @downloadURL https://openuserjs.org/install/Temm/HBRS_eva2_Timetable_Cleaner.user.js // @match https://eva2.inf.h-brs.de/stundenplan/anzeigen/*mode=grid* // @icon https://www.google.com/s2/favicons?sz=64&domain=h-brs.de // @grant none // @copyright 2022, Temm (https://openuserjs.org/users/Temm) // @license GPL-3.0-or-later // ==/UserScript== console.log("Loaded Timetable Cleaner!"); (function () { //#region Element Helper Functions /** @type {(s: string)=>HTMLElement} */ const $ = document.querySelector.bind(document); /** @type {(s: string)=>NodeListOf<HTMLElement>} */ const $$ = document.querySelectorAll.bind(document); //#endregion //#region Interface Helper Functions /** @argument {HTMLElement} element */ function getTableCoordsOfModule(element) { let rows = Array.from($$("tr[class^='row']")); let row = rows.find(row => Array.from(row.children).includes(element)); let y = rows.indexOf(row); let x = 0; for (const elem of row.children) { if (elem === element) break; x += parseInt(elem.getAttribute("colspan") ?? 1); } return { x, y }; } /** * @argument {number} x * @argument {number} y */ function getModuleOfTableCoords(x, y) { let rows = Array.from($$("tr[class^='row']")); let row = rows[y]; for (const elem of row.children) { if (elem.matches(".row-label-one")) continue; if (x === 0) return elem; x -= elem.getAttribute("colspan") || 1; } } /** @argument {HTMLElement} module */ function isSpaceFree(row, start, width) { let rows = Array.from($$("tr[class^='row']")); let cols = Array.from(rows[row].querySelectorAll("td:not(.row-label-one)")); let currStart = 0; let end = start + width; for (const elem of cols) { let currWidth = parseInt(elem.getAttribute("colspan") ?? 1); let currEnd = currStart + currWidth; // check if currStart->currEnd collides with start->end if (!elem.matches(".cell-border")) { if (currEnd >= start) return false; } currStart += currWidth; if (currStart >= end) return true; } return true; } /** @argument {HTMLElement} module */ function removeModule(module) { let length = module.getAttribute("colspan"); for (let i = 0; i < length; i++) { module.before(empty.cloneNode()) } module.remove(); } /** @argument {HTMLElement} module */ function findDayOfModule(module) { for (const day of days) { if (day.some(row => row.contains(module))) return day; } } /** @argument {HTMLElement|number} row */ function findDayOfRow(row) { if (typeof row === "number") { row = $$("tr[class^='row']")[row]; } for (const day of days) { if (day.includes(row)) return day; } } function cleanEmptyRows() { let rows = $$("tr[class^='row']"); rows.forEach((row) => { let children = Array.from(row.children); if (!children.some(cell => cell.classList.contains("object-cell-border"))) { // Row has no modules let dayLabel = findDayOfRow(row)[0].children[0]; let day = findDayOfRow(row); // There are no modules in this row if (row.children[0].classList.contains("row-label-one")) { // Day label found in row, it will have to be moved down before removing row // This is the only row of the day, keep it. if (day.length == 1) return; day[1].children[0].before(dayLabel); } // There are no modules or day labels in this row, remove it // Reduce day label height so it doesnt overflow into next day dayLabel.setAttribute("rowspan", parseInt(dayLabel.getAttribute("rowspan")) - 1) row.remove(); day.splice(day.indexOf(row), 1); } }); } //#endregion //#region Data variables let empty = $(".cell-border").cloneNode(); let modules = Array.from($$(".object-cell-border")); /** @type {HTMLElement[][]} */ let days = Array.from($$(".container-fluid>table>tbody>tr:not(:first-child)")).reduce((reduced, elem) => { if (!Array.from(elem.classList).some(c => c.startsWith("row"))) return reduced; if (elem.children[0].classList.contains("row-label-one")) { // Day label found in row, new day started reduced.push([]); } reduced[reduced.length - 1].push(elem); return reduced; }, []); //#endregion console.log("Days:", days); let interface = document.createElement("div"); //#region Module Data Processing and UI let rawData = {} const groupRegex = /(?:[^a-zA-Z])Gr(?:\.| |\. )((?:Wdh\.? )?[a-z0-9\-]{1,6})/i; const typeRegex = /\((Ü|V|P)\)/i; for (let module of modules) { module = module.querySelector(".nobreak"); let title = module.querySelector(".lvtitel").textContent; let cleanTitle = title; cleanTitle = cleanTitle.replace(/ +/g, " "); cleanTitle = cleanTitle .replace(groupRegex, "") .replace(typeRegex, "") .replace(/\(Online\)/i, "") .replace(/\./g, "") .trim(); cleanTitle = cleanTitle.replace(/ +/g, " "); if (rawData[title]) continue; let roomtime = module.querySelector(".lvraumzeit").textContent; let date = module.querySelector(".lvdatum").textContent; let prof = module.querySelector(".lvwer").textContent; let moduleGroup = (groupRegex.exec(title) ?? [, null])[1]; let moduleType = (typeRegex.exec(title) ?? [, null])[1]; rawData[title] = { title, cleanTitle, gruppe: moduleGroup, typ: moduleType, roomtime, date, prof, } } let organizedData = {}; for (let module of Object.values(rawData)) { if (!organizedData[module.cleanTitle]) organizedData[module.cleanTitle] = []; organizedData[module.cleanTitle].push(module); } let semesterId = decodeURIComponent(new URLSearchParams(window.location.search).get("identifier_semester") ?? "none"); let savedData = localStorage.getItem("timetableCleanerData_" + semesterId) savedData = savedData ? JSON.parse(savedData) : { version: 1, hiddenModules: [], }; console.log("Loaded Saved Data", savedData, "for semester id:", semesterId); let sortedModules = Object.values(organizedData).sort((a, b) => b.length - a.length); for (const module of sortedModules) { let select = document.createElement("select"); select.multiple = true; select.id = "module-" + module[0].cleanTitle; // possibly cursed select.style.width = "400px"; select.style.fontSize = "12px"; select.size = 5; for (const submodule of module) { let option = document.createElement("option"); option.value = submodule.title; option.text = submodule.title; option.selected = !savedData.hiddenModules.includes(submodule.title); select.appendChild(option); } interface.appendChild(select); } //#endregion //#region UI Functionality let uiControls = document.createElement("div"); uiControls.style.display = "inline-block"; let cleanupCheckbox = document.createElement("input"); cleanupCheckbox.type = "checkbox"; cleanupCheckbox.id = "cleanup"; cleanupCheckbox.checked = true; uiControls.appendChild(cleanupCheckbox); let cleanupLabel = document.createElement("label"); cleanupLabel.textContent = "Clean up rows (Sort Modules Upwards)"; cleanupLabel.setAttribute("for", "cleanup"); cleanupLabel.style.marginBottom = "0px"; uiControls.appendChild(cleanupLabel); uiControls.appendChild(document.createElement("br")); // let highlightCheckbox = document.createElement("input"); // highlightCheckbox.type = "checkbox"; // highlightCheckbox.id = "highlight"; // highlightCheckbox.checked = true; // uiControls.appendChild(highlightCheckbox); // let highlightLabel = document.createElement("label"); // highlightLabel.textContent = "Highlight free time"; // highlightLabel.setAttribute("for", "highlight"); // uiControls.appendChild(highlightLabel); // uiControls.appendChild(document.createElement("br")); let btn = document.createElement("button"); btn.textContent = "Apply Filter"; btn.onclick = () => { // Apply filters btn.disabled = true; cleanupCheckbox.disabled = true; console.log(modules.length) let hiddenModuleNames = Array.from($$("select[id^='module-']")) .map(s => Array.from(s.querySelectorAll("option:not(:checked)"))) .flat().map(o => o.value); savedData.hiddenModules = hiddenModuleNames; localStorage.setItem("timetableCleanerData_" + semesterId, JSON.stringify(savedData)); hiddenModuleNames.forEach(title => { let removedModules = modules.filter(m => m.querySelector(".lvtitel").textContent == title); for (const module of removedModules) { removeModule(module); } }); $$("select[id^='module-']").forEach(s => s.disabled = true); if (cleanupCheckbox.checked) { let runNext = true; while (runNext) { runNext = false; let modules = Array.from($$(".object-cell-border")); for (const module of modules) { let day = findDayOfModule(module); console.log("the day is ", day); let { x, y } = getTableCoordsOfModule(module); let width = parseInt(module.getAttribute("colspan") ?? 1); if (y == 0) continue; let aboveModuleDay = findDayOfRow(y - 1); if (day == aboveModuleDay && isSpaceFree(y - 1, x, width)) { // Module can be moved up! runNext = true; for (let i = 0; i < width; i++) { module.before(empty.cloneNode()); } let toReplace = getModuleOfTableCoords(x, y - 1); for (let i = 1; i < width; i++) { toReplace.nextElementSibling.remove(); } getModuleOfTableCoords(x, y - 1).replaceWith(module); } } } } cleanEmptyRows(); } uiControls.appendChild(btn); interface.appendChild(uiControls); //#endregion $(".container-fluid").children[0].after(interface); //#region Debug utilities $$(".object-cell-border").forEach((o) => { o.addEventListener("click", () => { removeModule(o) cleanEmptyRows(); }); }); //#endregion })();