diff --git a/src/background.ts b/src/background.ts index a96cb847..8750b6b5 100644 --- a/src/background.ts +++ b/src/background.ts @@ -21,7 +21,8 @@ chrome.runtime.onInstalled.addListener(async (details) => { theme: 'system', studiengang: 'general', hisqisPimpedTable: true, - bannersShown: ['mv3UpdateNotice'] + bannersShown: ['mv3UpdateNotice'], + improveSelma: true, }) await openSettingsPage('first_visit') break @@ -36,6 +37,7 @@ chrome.runtime.onInstalled.addListener(async (details) => { 'theme', 'studiengang', 'hisqisPimpedTable', + 'improveSelma', 'savedClickCounter', 'saved_click_counter', // legacy 'Rocket', // legacy @@ -57,6 +59,7 @@ chrome.runtime.onInstalled.addListener(async (details) => { if (typeof currentSettings.dashboardDisplay === 'undefined') updateObj.dashboardDisplay = 'favoriten' if (typeof currentSettings.fwdEnabled === 'undefined') updateObj.fwdEnabled = true if (typeof currentSettings.hisqisPimpedTable === 'undefined') updateObj.hisqisPimpedTable = true + if (typeof currentSettings.improveSelma === 'undefined') updateObj.improveSelma = true if (typeof currentSettings.theme === 'undefined') updateObj.theme = 'system' if (typeof currentSettings.studiengang === 'undefined') updateObj.studiengang = 'general' if (typeof currentSettings.selectedRocketIcon === 'undefined') updateObj.selectedRocketIcon = JSON.stringify(rockets.default) diff --git a/src/contentScripts/other/selma/layout.ts b/src/contentScripts/other/selma/layout.ts new file mode 100644 index 00000000..0f0a03b8 --- /dev/null +++ b/src/contentScripts/other/selma/layout.ts @@ -0,0 +1,550 @@ +const currentView = document.location.pathname +// Regex for extracting Programm name and arguments from a popup Script +// This is used to get the URL which would be opened in a popup +const popupScriptsRegex = + /dl_popUp\("\/scripts\/mgrqispi\.dll\?APPNAME=CampusNet&PRGNAME=(\w+)&ARGUMENTS=([^"]+)"/ + +function scriptToURL (script: string): string { + const matches = script.match(popupScriptsRegex)! + + const porgamName = matches.at(1)! + const prgArguments = matches.at(2)! + + return `https://selma.tu-dresden.de/APP/${porgamName}/${prgArguments}` +} + +function mapGrade (gradeElm: Element) { + const grade = gradeElm.textContent! + + if (grade.includes('be')) { + gradeElm.textContent = '✔' + gradeElm.setAttribute('title', 'Bestanden') + } else if (grade.includes('noch nicht gesetzt')) { + gradeElm.textContent = '🕓' + gradeElm.setAttribute('title', 'Noch nicht gesetzt') + } +} + +function injectCSS (filename: string) { + const style = document.createElement('link') + style.rel = 'stylesheet' + style.type = 'text/css' + style.href = chrome.runtime.getURL( + `styles/contentScripts/selma/${filename}.css` + ); + + (document.head || document.body || document.documentElement).appendChild( + style + ) +} + +/* +--- + +Proabably a proper bundler config would be better + +--- +*/ + +namespace Graphing { + export type GradeStat = { + grade: number; + count: number; + }; + + function maxGradeCount (values: GradeStat[]): number { + let max = 0 + for (const { count } of values) { + if (count > max) max = count + } + return max + } + + // Reduce the grade increments + function pickGradeSubset (values: GradeStat[]): GradeStat[] { + const increments = [1, 1.3, 1.7, 2, 2.3, 2.7, 3, 3.3, 3.7, 4, 5] + + const newValues = increments.map((inc) => ({ + grade: inc, + count: 0 + })) + + let currentIncIndex = 0 + for (const { grade, count } of values) { + // Skip to next increment if we reached it's lower end + if (currentIncIndex !== increments.length - 1) { + const nextIncrement = increments[currentIncIndex + 1] + if (grade >= nextIncrement) currentIncIndex++ + } + newValues[currentIncIndex].count += count + } + + return newValues + } + + export function createSVGGradeDistributionGraph ( + values: GradeStat[], + url: string, + width = 200, + height = 100 + ): string { + // Reduce the bar count / pick bigger intervals + const coarseValues = pickGradeSubset(values) + + // Spacing in percent of bar width + const spacing = 0.1 + const barWidth = (width * (1 - spacing)) / coarseValues.length + + // Drawing the Chart + let barsSvg = '' + const maxCount = maxGradeCount(coarseValues) + for (let x = 0; x < coarseValues.length; x++) { + const { grade, count } = coarseValues[x] + const barHeight = (count / maxCount) * height + + // Allows styling the failed sections differently + let className = 'passed' + if (grade >= 5.0) className = 'failed' + + barsSvg += ` + + ${grade.toFixed(2)} + + ` + } + + return ` + + + + + + ${barsSvg} + + + ` + } + + export type Try = { date: string; grade: string }; + + export function createJExamTryCounter ( + tries: Try[], + url: string, + width = 200 + ): string { + // Spacing in percent of circle width + const spacing = 0.2 + // Stroke width in percent of radius + const strokeWidth = 0.12 + + const filledRadius = (width * (1 - spacing)) / 6 + const strokedRadius = filledRadius * (1 - strokeWidth) + // +1 to prevent weird cut off + const height = Math.ceil(2 * filledRadius) + 1 + + // Drawing the Chart + let svgContent = '' + + for (let x = 0; x < 3; x++) { + let className = 'used' + let tooltip = '' + if (x >= tries.length) { + // Mark open try + className = 'open' + } else { + const { date, grade } = tries[x] + tooltip = `${grade}\n${date}` + } + + svgContent += ` + + ${tooltip} + + ` + } + + return ` + + + + + + ${svgContent} + + + ` + } +} + +/* +--- + +Actual logic + +--- +*/ + +// Create a small banner that indicates the user that the site was modified +// It also adds a small toggle to disable the table +async function createCreditsBanner() { + const { improveSelma: settingEnabled } = await chrome.storage.local.get(['improveSelma']) + + const imgUrl = chrome.runtime.getURL('/assets/images/tufast48.png') + const credits = document.createElement('p') + + credits.style.margin = 'auto' + credits.style.marginRight = '0' + credits.style.color = '#002557' // Selma theme color + credits.id = 'TUfastCredits' + credits.innerHTML = `Table ${settingEnabled ? 'powered by' : 'disabled'} + + TUfast + by AKORA + ` + + const disableButton = document.createElement('button') + // Similiar style to logout button + disableButton.setAttribute( + 'style', + ` + border: 1px solid rgb(255, 255, 255); + color: rgb(221, 39, 39); + text-decoration: none; + padding: 0.5rem 1rem; + margin: 0 1rem; + border-radius: 0px; + ` + ) + + // Tooltip + disableButton.title = + 'Toggle the "ImproveSelma" feature and reload the page to apply the change.' + disableButton.textContent = settingEnabled ? 'Deactivate' : 'Activate' + disableButton.onclick = async (event) => { + event.preventDefault() + await chrome.storage.local.set({ improveSelma: !settingEnabled }) + window.location.reload() + } + credits.appendChild(disableButton) + + return credits +} + +(async () => { + const { improveSelma } = await chrome.storage.local.get(['improveSelma']) + + // Apply all custom changes + document.addEventListener('DOMContentLoaded', async () => { + // Add Credit banner with toggle button + const creditElm = await createCreditsBanner() + document.querySelector('.semesterChoice')!.appendChild(creditElm) + + if (!improveSelma) return + + eventListener(); + }) +})() + +async function eventListener () { + document.removeEventListener('DOMContentLoaded', eventListener) + + // Inject css + injectCSS('base') + if ( + currentView.startsWith('/APP/EXAMRESULTS/') || + currentView.startsWith('/APP/COURSERESULTS/') + ) { + injectCSS('exam_results') + } + if (currentView.startsWith('/APP/MYEXAMS/')) { + injectCSS('my_exams') + } + + applyChanges() +} + +function applyChanges () { + if (currentView.startsWith('/APP/EXAMRESULTS/')) { + // Prüfungen > Ergebnisse + + // Remove the "gut/befriedigend" section + const headRow = document.querySelector('thead>tr')! + headRow.removeChild(headRow.children.item(3)!) + headRow.children.item(3)!.textContent = 'Notenverteilung' + + const body = document.querySelector('tbody')! + const promises: Promise<{ doc: Document; elm: Element; url: string }>[] = + [] + for (const row of body.children) { + // Remove useless inline styles which set the vertical alignment + for (const col of row.children) col.removeAttribute('style') + + row.removeChild(row.children.item(3)!) + + // Extract script content + const lastCol = row.children.item(3)! + const scriptElm = lastCol.children.item(1) + if (scriptElm === null) continue + + const scriptContent = scriptElm!.innerHTML + + const url = scriptToURL(scriptContent) + + promises.push( + fetch(url).then(async (s) => { + const parser = new DOMParser() + const doc = parser.parseFromString(await s.text(), 'text/html') + + return { doc, elm: lastCol, url } + }) + ) + } + + promises.forEach((p) => + p.then(({ doc, elm, url }) => { + const tableBody = doc.querySelector('tbody')! + const values = [...tableBody.children].map((tr) => { + const gradeText = tr.children.item(0)!.textContent!.replace(',', '.') + const grade = parseFloat(gradeText) + + const countText = tr.children.item(1)!.textContent! + let count: number + if (countText === '---') count = 0 + else count = parseInt(countText) + + return { + grade, + count + } + }) + // .slice(0, -2); // Remove the 5.0 from all lists + + // Present the bar chart + const graphSVG = Graphing.createSVGGradeDistributionGraph(values, url) + elm.innerHTML = graphSVG + }) + ) + + // Remove the inline style that sets a width on the top right table cell + const tableHeadRow = document.querySelector('thead>tr')! + tableHeadRow.children.item(3)!.removeAttribute('style') + /* + +*/ + } else if (currentView.startsWith('/APP/COURSERESULTS/')) { + // Prüfungen > Ergebnisse + + // Remove the "bestanden" section + const headRow = document.querySelector('thead>tr')! + headRow.removeChild(headRow.children.item(3)!) + + // Add "Notenverteilung" header + { + headRow.children.item(3)!.removeAttribute('colspan') + const newHeader = document.createElement('th') + newHeader.textContent = 'Notenverteilung' + headRow.appendChild(newHeader) + } + + // Create the grade distribution graph + const body = document.querySelector('tbody')! + const promises: Promise<{ doc: Document; elm: Element; url: string }>[] = + [] + for (const row of body.children) { + // Remove useless inline styles which set the vertical alignment + for (const col of row.children) col.removeAttribute('style') + + // Remove "Status" column + row.removeChild(row.children.item(3)!) + + { + // Map grade descriptions to emojis + const gradeElm = row.children.item(2)! + mapGrade(gradeElm) + } + + // Extract script content + const lastCol = row.children.item(4)! + const scriptElm = lastCol.children.item(1) + // Skip courses wihtout grades + if (scriptElm === null) continue + + const scriptContent = scriptElm!.innerHTML + + const url = scriptToURL(scriptContent) + + promises.push( + fetch(url).then(async (s) => { + const parser = new DOMParser() + const doc = parser.parseFromString(await s.text(), 'text/html') + + return { doc, elm: lastCol, url } + }) + ) + } + + promises.forEach((p) => + p.then(({ doc, elm, url }) => { + // Parse the grade distributions + const tableBody = doc.querySelector('tbody')! + const values = [...tableBody.children].map((tr) => { + const gradeText = tr.children.item(0)!.textContent!.replace(',', '.') + const grade = parseFloat(gradeText) + + const countText = tr.children.item(1)!.textContent! + let count: number + if (countText === '---') count = 0 + else count = parseInt(countText) + + return { + grade, + count + } + }) + // .slice(0, -2); // Remove the 5.0 from all lists + + // Present the bar chart + const graphSVG = Graphing.createSVGGradeDistributionGraph(values, url) + elm.innerHTML = graphSVG + }) + ) + + // Remove the inline style that sets a width on the top right table cell + const tableHeadRow = document.querySelector('thead>tr')! + tableHeadRow.children.item(3)!.removeAttribute('style') + + // Draw try counter in the jExam style + for (const row of body.children) { + const linkElm = row.children.item(3)! + const scriptElm = linkElm.children.item(1) + // Skip courses wihtout grades + if (scriptElm === null) continue + + // Extract script content + const scriptContent = scriptElm!.innerHTML + const url = scriptToURL(scriptContent) + + // Center the remaining "> Prüfung" links so it looks better after everything loaded + linkElm.setAttribute('style', 'text-align: center;') + + // Fetch data + fetch(url).then(async (s) => { + const parser = new DOMParser() + const doc = parser.parseFromString(await s.text(), 'text/html') + + // Extracting the grades of individual tries + const tableBody = doc.querySelector('tbody')! + const tries: Graphing.Try[] = [] + + // Search for tries + for (let i = 0; i < tableBody.children.length; i++) { + const trElm = tableBody.children.item(i)! + const firstTd = trElm.querySelector('td.level02') + + // Before a row with a grade there is always a row containing "Modulprüfung" + if (firstTd !== null && firstTd.textContent === 'Modulprüfung') { + // Next row will contain a try with a grade + let nextTrElm = tableBody.children.item(i + 1)! + // Sometimes there is an extra row + if (nextTrElm.children.length === 1) { + nextTrElm = tableBody.children.item(i + 2)! + } + + // Extract information + const date = nextTrElm.children.item(2)!.textContent!.trim() + const grade = nextTrElm.children.item(3)!.textContent!.trim() + tries.push({ date, grade }) + + i += 2 + continue + } + } + + // Unable to parse the grades from the tables + if (tries.length === 0) return + + // Replace link with a chart + linkElm.innerHTML = Graphing.createJExamTryCounter(tries, url) + }) + } + + /* + +*/ + } else if (currentView.startsWith('/APP/MYEXAMS/')) { + // Prüfungen + + const body = document.querySelector('tbody')! + const rows = [...body.children] + for (let i = 0; i < rows.length; i += 2) { + const topRow = rows[i] + const botRow = rows[i + 1] + + const thElm = topRow.children.item(0)! + thElm.className += ' module-description' + // moduleCode, hyperlink, space, br, description + const [, , , , description] = thElm.childNodes + + { + // Move exam type and examinant to the right side + thElm.setAttribute('colspan', '2') + const newSpacer = document.createElement('th') + newSpacer.setAttribute('colspan', '2') + newSpacer.replaceChildren(...botRow.children.item(1)!.children) + topRow.appendChild(newSpacer) + } + + { + // Move the description under the exam title + // Remove useless first element + botRow.removeChild(botRow.children.item(1)!) + const newDescriptionElm = botRow.children.item(0)! + newDescriptionElm.setAttribute('colspan', '2') + newDescriptionElm.className += ' module-description' + + // Some entries do not have a description + if (thElm.childNodes.length === 5) { + newDescriptionElm.appendChild(description) + } + } + + { + // Remove useless timespans + const dateElm = botRow.children.item(1)! + dateElm.textContent = dateElm.textContent!.replaceAll( + '00:00-00:00', + '' + ) + } + + // Table head "Prüfungsleistung" + document.querySelector('thead > tr > th#Name')!.textContent = '' + // Table head "Termin" + document.querySelector('thead > tr > th#Date')!.textContent = + 'Prüfungsleistung/Termin' + } + } +} diff --git a/src/freshContent/settings/Settings.vue b/src/freshContent/settings/Settings.vue index 578ec5bd..756a7e48 100644 --- a/src/freshContent/settings/Settings.vue +++ b/src/freshContent/settings/Settings.vue @@ -78,6 +78,7 @@ import AutoLogin from './settingPages/AutoLogin.vue' import Email from './settingPages/Email.vue' import OpalCourses from './settingPages/OpalCourses.vue' import ImproveOpal from './settingPages/ImproveOpal.vue' +import ImproveSelma from './settingPages/ImproveSelma.vue' import Shortcuts from './settingPages/Shortcuts.vue' import SearchEngines from './settingPages/SearchEngines.vue' import Rockets from './settingPages/Rockets.vue' @@ -115,6 +116,7 @@ export default defineComponent({ Email, OpalCourses, ImproveOpal, + ImproveSelma, Shortcuts, SearchEngines, Rockets, diff --git a/src/freshContent/settings/components/SettingTile.vue b/src/freshContent/settings/components/SettingTile.vue index 21b93791..9e7c2ca9 100644 --- a/src/freshContent/settings/components/SettingTile.vue +++ b/src/freshContent/settings/components/SettingTile.vue @@ -19,7 +19,7 @@ + + diff --git a/src/freshContent/settings/settings.json b/src/freshContent/settings/settings.json index 782c58aa..a3ef9cad 100644 --- a/src/freshContent/settings/settings.json +++ b/src/freshContent/settings/settings.json @@ -19,6 +19,11 @@ "icon": "PhSparkle", "settingsPage": "ImproveOpal" }, + { + "title": "Selma verbessern", + "icon": "PhChartBar", + "settingsPage": "ImproveSelma" + }, { "title": "Shortcuts", "icon": "PhGauge", diff --git a/src/manifest.chrome.json b/src/manifest.chrome.json index 8aac4225..6d6631b2 100644 --- a/src/manifest.chrome.json +++ b/src/manifest.chrome.json @@ -83,6 +83,11 @@ "run_at": "document_idle", "matches": ["https://selma.tu-dresden.de/*"] }, + { + "js": ["contentScripts/other/selma/layout.js"], + "run_at": "document_start", + "matches": ["https://selma.tu-dresden.de/*"] + }, { "js": ["contentScripts/login/qis.js"], "run_at": "document_idle", @@ -263,6 +268,10 @@ "snowpack/pkg/*" ], "matches": ["https://qis.dez.tu-dresden.de/*"] + }, + { + "resources": ["styles/contentScripts/selma/*"], + "matches": ["https://selma.tu-dresden.de/*"] }], "manifest_version": 3, "commands": { diff --git a/src/manifest.firefox.json b/src/manifest.firefox.json index 0f706290..6f3c1a8d 100644 --- a/src/manifest.firefox.json +++ b/src/manifest.firefox.json @@ -2,10 +2,7 @@ "name": "TUfast TU Dresden", "version": "8.1.0.1", "description": "Das Produktivitäts-Tool für TU Dresden Studierende 🚀", - "permissions": [ - "storage", - "alarms" - ], + "permissions": ["storage", "alarms"], "optional_permissions": [ "tabs", "notifications", @@ -13,9 +10,7 @@ "webRequest", "webRequestBlocking" ], - "host_permissions": [ - "*://*/" - ], + "host_permissions": ["*://*/"], "background": { "scripts": ["background.js"], "type": "module" @@ -83,6 +78,11 @@ "run_at": "document_idle", "matches": ["https://selma.tu-dresden.de/*"] }, + { + "js": ["contentScripts/other/selma/layout.js"], + "run_at": "document_start", + "matches": ["https://selma.tu-dresden.de/*"] + }, { "js": ["contentScripts/login/qis.js"], "run_at": "document_idle", @@ -223,47 +223,47 @@ "page": "freshContent/settings/index.html", "open_in_tab": true }, - "web_accessible_resources": [{ - "resources": [ - "assets/*", - "contentScripts/other/notification.js" - ], - "matches": [""] - }, - { - "resources": ["contentScripts/login/common.js"], - "matches": [ - "https://*.tu-dresden.de/*", - "https://bildungsportal.sachsen.de/*", - "https://videocampus.sachsen.de/*", - "https://git.imld.de/*", - "https://gitlab.hrz.tu-chemnitz.de/*", - "https://*.slub-dresden.de/*" - ] - }, - { - "resources": [ - "contentScripts/forward/searchEngines/common.js", - "contentScripts/forward/searchEngines/sites.json*" - ], - "matches": [ - "https://www.startpage.com/*", - "https://www.qwant.com/*", - "https://www.google.de/*", - "https://www.google.com/*", - "https://duckduckgo.com/*", - "https://www.ecosia.org/*", - "https://www.bing.com/*", - "https://search.brave.com/*" - ] - }, - { - "resources": [ - "contentScripts/other/hisqis/*", - "snowpack/pkg/*" - ], - "matches": ["https://qis.dez.tu-dresden.de/*"] - }], + "web_accessible_resources": [ + { + "resources": ["assets/*", "contentScripts/other/notification.js"], + "matches": [""] + }, + { + "resources": ["contentScripts/login/common.js"], + "matches": [ + "https://*.tu-dresden.de/*", + "https://bildungsportal.sachsen.de/*", + "https://videocampus.sachsen.de/*", + "https://git.imld.de/*", + "https://gitlab.hrz.tu-chemnitz.de/*", + "https://*.slub-dresden.de/*" + ] + }, + { + "resources": [ + "contentScripts/forward/searchEngines/common.js", + "contentScripts/forward/searchEngines/sites.json*" + ], + "matches": [ + "https://www.startpage.com/*", + "https://www.qwant.com/*", + "https://www.google.de/*", + "https://www.google.com/*", + "https://duckduckgo.com/*", + "https://www.ecosia.org/*", + "https://www.bing.com/*", + "https://search.brave.com/*" + ] + }, + { + "resources": ["contentScripts/other/hisqis/*", "snowpack/pkg/*"], + "matches": ["https://qis.dez.tu-dresden.de/*"] + }, + { + "resources": ["styles/contentScripts/selma/*"], + "matches": ["https://selma.tu-dresden.de/*"] + } + ], "manifest_version": 3, "browser_specific_settings": { "gecko": { diff --git a/src/styles/contentScripts/selma/base.scss b/src/styles/contentScripts/selma/base.scss new file mode 100644 index 00000000..3b19e273 --- /dev/null +++ b/src/styles/contentScripts/selma/base.scss @@ -0,0 +1,5 @@ +.pageContent { + min-width: unset; + max-width: unset; + padding: 3rem 5vw 5rem 5rem; +} diff --git a/src/styles/contentScripts/selma/exam_results.scss b/src/styles/contentScripts/selma/exam_results.scss new file mode 100644 index 00000000..2c03dd0d --- /dev/null +++ b/src/styles/contentScripts/selma/exam_results.scss @@ -0,0 +1,107 @@ +// Better spacing of the table content +table { + line-height: 1.5; +} + +// Alternating background and separator lines +tbody > tr > th { + padding-top: 2rem; + padding-bottom: 0.5rem; + background: unset; +} + +tbody > tr > td { + background: unset; + border-bottom: 1px solid #ccc; +} + +tbody :nth-child(2n of tr) { + background: #e7eaf0; +} + +tbody > tr ::first-line { + color: #002557; +} + +// Make the bottom text gray in the "Prüfungsleistung section" +tbody > tr > td ::first-line { + color: #666; +} + +// Vertically center the text in all courseresult rows +tbody > tr > td.tbdata { + vertical-align: middle; +} + +// The diagram +tbody > tr > td > svg { + display: block; + margin-left: auto; + margin-right: auto; + + .passed { + fill: #315584; + } + + .failed { + fill: #dd2727aa; + } + + .used { + fill: #315584; + } + + .open { + fill: none; + stroke: #315584aa; + } + + &.distribution-chart { + height: 3lh; + } + + &.tries-counter { + height: 1lh; + } +} + +// Not sure if this helps +tbody > tr > td :has(svg) { + vertical-align: middle; +} + +//Courseresults page +tbody > tr > td.tbdata > svg.distribution-chart { + height: 2lh; +} + +// Date styling +// tr.tbdata means it only applies to the Examresults page +tbody > tr.tbdata :nth-child(2 of td) { + vertical-align: middle; +} +// td.tbdata means it only applies to the Courseresults page +tbody > tr :nth-child(3 of td.tbdata) { + vertical-align: middle; +} + +// Grade styling +tbody > tr :nth-child(3 of td) { + vertical-align: middle; + text-align: center; + font-size: 1.7rem; +} + +// Align the Header texts +thead > tr { + // "Note" / "Modulenote" + :nth-child(3) { + text-align: center; + } + + // "Notenverteilung" + :nth-last-child(1), + :nth-last-child(2) { + text-align: center; + } +} diff --git a/src/styles/contentScripts/selma/my_exams.scss b/src/styles/contentScripts/selma/my_exams.scss new file mode 100644 index 00000000..15bd6533 --- /dev/null +++ b/src/styles/contentScripts/selma/my_exams.scss @@ -0,0 +1,24 @@ +//Alternating background +tbody > tr { + > th { + padding-top: 2rem; + padding-bottom: 0.5rem; + background: unset; + } + + > td { + background: unset; + border-bottom: 1px solid #ccc; + padding-top: 0.5rem; + padding-bottom: 2rem; + + &.module-description { + padding-right: 2rem; + } + } +} + +tbody :nth-child(4n of tr), +tbody :nth-child(4n - 1 of tr) { + background: #e7eaf0; +}