From 6839831af54cd9302119bcc3bffafe10475958d4 Mon Sep 17 00:00:00 2001 From: Florian Fischer Date: Mon, 3 Aug 2026 14:31:25 +0200 Subject: refactor project structure * merge sites into single project root * fix name of mass-graduation site * enable typescript support --- src/admin-tools.js | 57 +++++++ src/cockpit.js | 366 +++++++++++++++++++++++++++++++++++++++++++ src/config.js | 0 src/database.js | 160 +++++++++++++++++++ src/graduation-common.js | 396 +++++++++++++++++++++++++++++++++++++++++++++++ src/graduation-mock.js | 30 ++++ src/graduation-prod.js | 15 ++ src/group.js | 57 +++++++ src/mass-graduation.js | 357 ++++++++++++++++++++++++++++++++++++++++++ src/mock.js | 104 +++++++++++++ src/selection.js | 170 ++++++++++++++++++++ src/student-common.js | 115 ++++++++++++++ src/student-mock.js | 9 ++ src/student-prod.js | 2 + src/utils-common.ts | 25 +++ src/utils-mock.js | 41 +++++ src/utils-prod.js | 36 +++++ 17 files changed, 1940 insertions(+) create mode 100644 src/admin-tools.js create mode 100644 src/cockpit.js create mode 100644 src/config.js create mode 100644 src/database.js create mode 100644 src/graduation-common.js create mode 100644 src/graduation-mock.js create mode 100644 src/graduation-prod.js create mode 100644 src/group.js create mode 100644 src/mass-graduation.js create mode 100644 src/mock.js create mode 100644 src/selection.js create mode 100644 src/student-common.js create mode 100644 src/student-mock.js create mode 100644 src/student-prod.js create mode 100644 src/utils-common.ts create mode 100644 src/utils-mock.js create mode 100644 src/utils-prod.js (limited to 'src') diff --git a/src/admin-tools.js b/src/admin-tools.js new file mode 100644 index 0000000..b0cf80d --- /dev/null +++ b/src/admin-tools.js @@ -0,0 +1,57 @@ +import { DB } from "./database.js"; +import { fetchStudents } from "./student-ENV.js"; + +let outputNode; +function output(msg) { + if (outputNode.style.display === "none") { + outputNode.style.display = "block"; + } + const line = document.createElement("div"); + line.innerHTML = msg; + line.classList.add("admin-tools-output-line"); + outputNode.appendChild(line); +} + +const studentsPromise = fetchStudents(); + +async function createGraduationDbs() { + const students = await studentsPromise; + + for (const student of students) { + if (!DB.getStudentDbs(student)) { + } + } +} + +async function init() { + outputNode = document.getElementById("admin-tools-output"); + const students = await studentsPromise; + + output("Suche nach Datenbanken ..."); + let missing = 0; + for (const student of students) { + const dbs = await DB.getStudentDbs(student); + if (!dbs) { + output(`\t${student.name}: Datenbanken fehlen`); + missing += 3; + continue; + } + for (const type of ["shadow", "public", "submit"]) { + if (!dbs[type]) { + output(`\t${student.name} ${student.group}: ${type} Datenbank fehlt`); + missing++; + } + } + } + if (missing) { + output(`${missing} von ${students.length * 3} Datenbanken fehlen!`); + } else { + output("Alle Datenbanken vorhanden!"); + } +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} diff --git a/src/cockpit.js b/src/cockpit.js new file mode 100644 index 0000000..bd4c860 --- /dev/null +++ b/src/cockpit.js @@ -0,0 +1,366 @@ +// import { + // fetchSubjectsMock as fetchSubjects, + // fetchProgressMock as fetchProgress, +// } from "../../js/mock.js"; + +import { + OBSERVATION_TYPES, + LEVEL_NAMES, + GraduationChange, + fetchGraduation, + fetchPublicGraduation, + submitGraduationChange +} from "./graduation-ENV.js"; + +import { fetchStudent, fetchStudents, Student } from "./student-ENV.js"; +import { + isTeacher as getIsTeacher, + getName, + getUserId, + DATE_FMT, + DATETIME_FMT, +} from "./utils-ENV.js"; +import { initSelection } from "./selection.js"; + +let isTeacher; +let name; +let student; + +async function loadStudent(studentToLoad) { + student = studentToLoad; + await fetchAndUpdateStudentInfo(); + // updateStudentCalendar(); + // updateStudentProgress(); + updateStudentGraduation(); +} + +function initStudentSelection() { + initSelection( + "student-selection", + fetchStudents, + (student) => `${student.name} ${student.getGroup()}`, + loadStudent + ); +} + +function pointClickHandler(color) { + const textareaDiv = document.getElementById("student-info-point-reason-container"); + const textarea = document.getElementById("student-info-point-reason"); + const submitBtn = document.getElementById("student-info-point-reason-submit"); + + // TODO(muhq): Prefill the textarea with common reasons + switch (color) { + case "green": + textarea.style.background = "rgb(from green r g b / 20%)" + textarea.value = ""; + break; + case "orange": + textarea.style.background = "rgb(from orange r g b / 20%)" + textarea.value = ""; + break; + case "red": + textarea.style.background = "rgb(from red r g b / 20%)" + textarea.value = ""; + break; + } + textareaDiv.style.display = "block"; + textarea.focus(); + + const coach = name; + + let submit = function() { + let change; + switch (color) { + case "green": + change = GraduationChange.newGreenPointChange(textarea.value, coach); + break; + case "orange": + change = GraduationChange.newOrangePointChange(textarea.value, coach); + break; + case "red": + change = GraduationChange.newRedPointChange(textarea.value, coach); + break; + default: + alert("Invalid color"); + break; + } + submitGraduationChange(student, change); + textareaDiv.style.display = "none"; + updateStudentInfo(); + student.fetchGraduation().then(() => updateStudentGraduation() ); + } + // Use onEVENT to override any previous handler + textarea.onkeydown = function(e) { + if (e.shiftKey && e.key === "Enter") { + submit(); + } + if (e.key === "Escape") { + textareaDiv.style.display = "none"; + } + }; + submitBtn.onclick = submit; +} + +async function fetchAndUpdateStudentInfo() { + if (isTeacher) + student.graduation = await fetchGraduation(student); + else + student.graduation = await fetchPublicGraduation(student); + + updateStudentInfo(); +} + +let DEFAULT_STUDENT_ICON; + +function updateStudentInfo() { + // Reset the student icon + document.getElementById("student-info-icon").src = DEFAULT_STUDENT_ICON; + + // Try to replace the generic icon with the user's icon + var img = document.createElement('img'); + img.onload = function() { + document.getElementById("student-info-icon").src = img.src; + img = img.onload = null; + }; + student.getIconUrl().then( (url) => img.src = url ); + + document.getElementById("student-info-name").innerHTML = `${student.name}`; + + if (student.graduation) { + const tag = document.getElementById("student-info-tag"); + tag.innerHTML = student.graduation.getLevelName(); + tag.style.display = "block"; + + document.getElementById("student-info-points-green").innerHTML = student.graduation.green; + document.getElementById("student-info-points-orange").innerHTML = student.graduation.orange; + document.getElementById("student-info-points-red").innerHTML = student.graduation.red; + + if (isTeacher) { + document.getElementById("student-info-green").addEventListener('click', + () => pointClickHandler("green")); + document.getElementById("student-info-orange").addEventListener('click', + () => pointClickHandler("orange")); + document.getElementById("student-info-red").addEventListener('click', + () => pointClickHandler("red")); + } + + renderGraduationLevel(student.graduation); + + // renderGraduationStickers(student.graduation, document.getElementById("student-info-stickers")); + // renderGraduationBonus(student.graduation, document.getElementById("student-info-stickers")); + + renderObservationStatus(student.graduation, document.getElementById("student-observation")); + } + + document.getElementById("student-content").style.display = "block"; +} + +function renderGraduationLevel(graduation) { + const label = document.getElementById("student-info-level"); + label.innerHTML = graduation.getLevelName(); + const icon = document.getElementById("student-level-icon"); + icon.src = graduation.getIcon(); +} + +// function renderGraduationStickers(graduation, node) { + // node.innerHTML = ""; +// } + +// function renderGraduationBonus(graduation, node) { + // node.innerHTML = ""; +// } + +function renderObservationStatus(graduation, node) { + node.innerHTML = ""; + + let status; + if (isTeacher || graduation.isObserved() || graduation.observationRequested) { + status = document.createElement("span"); + status.classList.add("student-observation-status"); + if (graduation.isObserved()) { + status.innerHTML = '👁 Ich befinde mich in der Beobachtungsphase zum Levelaufstieg.'; + } else if (graduation.observationRequested) { + status.innerHTML = 'Beobachtungsphase noch nicht begonnen.'; + } else { + status.innerHTML = "Nicht in Beobachtung."; + } + } else { + status = document.createElement("button"); + status.classList.add("student-request-observation-button"); + // Level is 1-based + status.innerHTML = `Ich will ${LEVEL_NAMES[graduation.level]} werden`; + status.addEventListener("click", async function() { + await graduation.requestObservation(); + // Rerender the observation status + renderObservationStatus(graduation, node); + }); + } + node.appendChild(status); +} + +// function newProgressBar(subject) { + // let bar = document.createElement("div"); + // fetchProgress(subject).then((progress) => { + // bar.progress = progress; + // }); + + // let s1 = document.createElement("div"); + // s1.onhover + + // bar.appendChild(s1); + // let s2 = document.createElement("div"); + // bar.appendChild(s2); + // let s3 = document.createElement("div"); + // bar.appendChild(s3); + // return bar; +// } + +// let selectedSubjects; +// async function updateStudentProgress() { + // selectedSubjects = await fetchSubjects(student); + + // let prog = document.getElementById("progress"); + // for(const subj of selectedSubjects) { + // let subjDiv = document.createElement("div"); + // subjDiv.innerHTML = subj; + // subjDiv.appendChild(newProgressBar(subj)) + // prog.appendChild(subjDiv); + // } +// } + +// async function updateStudentCalendar() { + // let cal = document.getElementById("calendar"); +// } + +async function updateStudentGraduation() { + if (!isTeacher) return; + console.assert(student.graduation, "Student has no graduation"); + const graduation = student.graduation; + + const changesTbl = document.getElementById("graduation-changes"); + changesTbl.innerHTML = ""; + for (const change of graduation.changes.filter( + (ch) => ["Grün", "Rot", "Orange"].includes(ch.change) )) + { + const changeTr = document.createElement("tr"); + for (const key in change) { + if (key == "json" || key == "id" || key == "name") continue; + + if (key == "change") { + switch(change.change) { + case "Grün": + changeTr.style.background = "green"; + break; + case "Rot": + changeTr.style.background = "red"; + break; + case "Orange": + changeTr.style.background = "orange"; + break; + default: + alert(`unexpected change: ${change.change}`); + } + continue; + } + + const value = change[key]; + const fmt = key == "date" ? DATETIME_FMT.format(value) : value; + + const changeTd = document.createElement("td"); + changeTd.innerHTML = fmt; + changeTd.dataset.key = key; + changeTr.appendChild(changeTd); + } + changesTbl.appendChild(changeTr); + } + + const observtionsTbl = document.getElementById("graduation-current-observation-period"); + observtionsTbl.innerHTML = ""; + if (graduation.observation) { + const sums = graduation.observation.getObservationsSums(); + for (let i = 0; i < OBSERVATION_TYPES.length; i++) { + const tr = document.createElement("tr"); + const typeTd = document.createElement("td"); + typeTd.innerHTML = OBSERVATION_TYPES[i]; + tr.appendChild(typeTd); + + const yesTd = document.createElement("td"); + yesTd.innerHTML = sums[i].yes; + tr.appendChild(yesTd); + + const noTd = document.createElement("td"); + noTd.innerHTML = sums[i].no; + tr.appendChild(noTd); + + observtionsTbl.appendChild(tr); + } + + const endDiv = document.getElementById("graduation-current-observation-period-end"); + if (!graduation.observation.ready()) { + endDiv.style.display = "none"; + return; + } + + endDiv.style.display = "block"; + + const reason = document.getElementById("graduation-current-observation-period-end-reason").value; + const successBtn = document.getElementById("graduation-current-observation-period-end-success"); + successBtn.addEventListener("click", () => { + submitGraduationChange(graduation.student, GraduationChange.newObservationEnd(reason, true)); + }); + const failureBtn = document.getElementById("graduation-current-observation-period-end-failure"); + failureBtn.addEventListener("click", () => { + submitGraduationChange(graduation.student, GraduationChange.newObservationEnd(reason, false)); + }); + } + + const pastObservtionsTbl = document.getElementById("graduation-observation-periods"); + pastObservtionsTbl.innerHTML = ""; + if (graduation.pastObservations.length > 0) { + for (const period of graduation.pastObservations) { + const tr = document.createElement("tr"); + const periodTd = document.createElement("td"); + const startDate = DATE_FMT.format(period.start); + const endDate = DATE_FMT.format(period.end); + periodTd.innerHTML = `${startDate} - ${endDate}`; + tr.appendChild(periodTd); + + const successTd = document.createElement("td"); + if (period.success) { + successTd.innerHTML = "Graduierung erfolgreich" + } else { + successTd.innerHTML = "Graduierung nicht erfolgreich" + } + tr.appendChild(successTd); + + pastObservtionsTbl.appendChild(tr); + } + } +} + +async function initCockpit() { + if (isTeacher) { + initStudentSelection(); + const params = new URLSearchParams(location.search); + const userId = params.get("userToLoad"); + if (userId) { + student = await fetchStudent(userId); + await loadStudent(student); + } + } else { + // Remove all elements only suitable for teachers + for(const elem of document.querySelectorAll("[data-availability=if-lehrer]")) { + elem.remove(); + } + + const student = await Student.fetchById(getUserId()); + await loadStudent(student); + } +} + +window.onload = function() { + isTeacher = getIsTeacher(); + DEFAULT_STUDENT_ICON = document.getElementById("student-info-icon").src; + name = getName(); + initCockpit(); +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..e69de29 diff --git a/src/database.js b/src/database.js new file mode 100644 index 0000000..e929896 --- /dev/null +++ b/src/database.js @@ -0,0 +1,160 @@ +import { getCourseId } from "./utils-ENV.js"; + +const DB_TEMPLATE_LIST_HEADER = ` + + + + + + + +`; +const DB_TEMPLATE_LIST_ENTRY = ` + + + + + +`; +const DB_TEMPLATE_LIST_FOOTER = ` + +
Json
##timeadded####user##[[Json]]
+`; + +let studentDbs = populateStudentDbs(); + +const ID_QUERY_PARAM_REGEX = /[?&]id=(\d+)/ + +async function populateStudentDbs() { + const courseUrl = `https://lernplattform.bycs.de/course/view.php?id=${getCourseId()}`; + const courseRes = await fetch(courseUrl); + const courseDoc = new DOMParser().parseFromString(await courseRes.text(), 'text/html'); + + let dbs = new Map(); + for (const link of courseDoc.querySelectorAll("div.activityname a.aalink")) { + if (link.textContent.trim() === "Submit Datenbank") { + const dbId = link.href.match(ID_QUERY_PARAM_REGEX)[1] + dbs.set("submit", new DB(dbId)); + continue; + } + + const match = link.textContent.match(/(\d+)(Shadow|Public|Submit)/); + if (!match) continue; + const studentId = match[1]; + const dbType = match[2]; + const dbId = link.href.match(ID_QUERY_PARAM_REGEX)[1] + + if (!dbs.has(studentId)) { + dbs.set(studentId, {}); + } + + dbs.get(studentId)[dbType.toLowerCase()] = new DB(dbId); + } + + return dbs; +} + +async function getSubmitDb() { + const dbs = await studentDbs; + return dbs.get("submit"); +} + +class DB { + constructor(id) { + this.id = id; + } + + async fetchEntries(params) { + return loadFromDatabaseActivity(this.id, params) + } + + async saveEntry(json) { + return addEntryToDatabaseActivity(this.id, json); + } + + static async getStudentDbs(student) { + const dbs = await studentDbs; + return dbs.get(student.id); + } +} + +async function loadFromDatabaseActivity(id, params) { + let dbUrl = `${window.location.origin}/mod/data/view.php?id=${id}&mode=list&perpage=1000`; + // TODO(muhq): Why does this not include entries? + if (params) { + dbUrl += "&filter=1&advanced=1"; + for (const param in params) { + dbUrl += `&${param}=${encodeURIComponent(params[param])}` + } + } + + const dbRes = await fetch(encodeURI(dbUrl)); + const dbDoc = new DOMParser().parseFromString(await dbRes.text(), 'text/html'); + let data = []; + dbDoc.querySelectorAll('.db-row').forEach(row => { + let entry = {}; + row.querySelectorAll('.db-cell').forEach(field => { + const key = field.dataset.key; + let value; + // Moodle includes the full date in the title attribute of the included span + if (key === "date") { + value = field.querySelector("span").title; + } else { + value = field.innerText.trim(); + } + entry[key] = value; + }); + data.push(entry); + }); + return data; +} + +async function loadFromStudentDatabase(student, type) { + const dbs = await DB.getStudentDbs(student); + const db = dbs[type]; + return await db.fetchEntries() +} + +async function addEntryToDatabaseActivity(id, json) { + const url = `/mod/data/edit.php?id=${id}`; + const editRes = await fetch(url); + const editDoc = new DOMParser().parseFromString(await editRes.text(), 'text/html'); + const form = editDoc.querySelector('div[role="main"] > form'); + + const formData = new FormData(form); + + for (const key of formData.keys()) { + if (key.startsWith("field_")) { + formData.set(key, json); + // We found our json key + break; + } + } + formData.set("saveandview", "Speichern"); + + return await fetch("/mod/data/edit.php", { + method: "POST", + // Set the FormData instance as the request body + body: formData, + }); +} + +async function addEntryToStudentDatabase(student, type, json) { + let db; + // global submit database + if (type === "submit") { + db = await getSubmitDb(); + // individuell databases + } else { + const dbs = await DB.getStudentDbs(student); + db = dbs[type]; + } + await db.saveEntry(json); +} + +export { + DB, + getSubmitDb, + loadFromDatabaseActivity, loadFromStudentDatabase, + addEntryToDatabaseActivity, addEntryToStudentDatabase, +}; diff --git a/src/graduation-common.js b/src/graduation-common.js new file mode 100644 index 0000000..0c28465 --- /dev/null +++ b/src/graduation-common.js @@ -0,0 +1,396 @@ +import { getSubmitDb, loadFromStudentDatabase, addEntryToStudentDatabase } from "./database.js" + +import NeulingPng from "../assets/Neuling.png"; +import EntdeckerPng from "../assets/Entdecker.png"; +import NavigatorPng from "../assets/Navigator.png"; +import PioneerPng from "../assets/Pioneer.png"; + +const LEVEL_NAMES = ["Neuling", "Entdecker", "Navigator", "Pionieer"]; + +const LEVEL_IMAGES = [ + new URL(NeulingPng), + new URL(EntdeckerPng), + new URL(NavigatorPng), + new URL(PioneerPng), +]; + +class GraduationChange { + constructor(date, change, reason, coach, context) { + this.date = date; + this.change = change; + this.reason = reason; + this.coach = coach; + this.context = context; + } + + isPublic() { + return [ + "Grün", "Orange", "Rot", + "ObservationStart", "ObservationEnd", + "LevelChange" + ].includes(this.change); + } + + publicDBFields () { + return { + "change": this.change, + "date": this.date, + }; + } + + dbFields () { + return { + // We include the date in the json because Moodle reports the timeadded + // locale dependent. + "date": this.date, + "change": this.change, + "reason": this.reason, + "context": this.context, + }; + } + + static fromDBEntry(e) { + const {coach, json} = e; + const change = JSON.parse(json); + return new GraduationChange(new Date(change.date), change.change, change.reason, coach, change.context); + } + + static newObservationRequest() { + return new GraduationChange(new Date(), "ObservationRequest", "", "", ""); + } + + static newObservationStart() { + return new GraduationChange(new Date(), "ObservationStart", "", "", ""); + } + + static newObservationEnd(reason, success) { + return new GraduationChange(new Date(), "ObservationEnd", reason, "", JSON.stringify(success)); + } + + static newObservationChange(observation) { + return new GraduationChange(observation.date, "Observation", "", "", JSON.stringify(observation.status)); + } + + static newGreenPointChange(reason, coach) { + return new GraduationChange(new Date(), "Grün", reason, coach, ""); + } + + static newOrangePointChange(reason, coach) { + return new GraduationChange(new Date(), "Orange", reason, coach, ""); + } + + static newRedPointChange(reason, coach) { + return new GraduationChange(new Date(), "Rot", reason, coach, ""); + } +} + +class Graduation { + constructor(student, green, orange, red, level, stickers, observationRequested, observation, pastObservations, changes) { + this.student = student; + this.green = green; + this.orange = orange; + this.red = red; + this.level = level; + this.stickers = stickers; + this.observationRequested = observationRequested; + this.observation = observation; + this.pastObservations = pastObservations; + this.changes = changes; + + student.graduation = this; + } + + static from(student, changes) { + let green = 0; + let orange = 0; + let red = 0; + let level = 1; + let openObservationRequest = null; + let observationPeriod = null; + let pastObservations = []; + let stickers = []; + + // The parsing logic assumes that changes are ordered chronologically + changes.sort((a, b) => a.date - b.date); + + for (const change of changes) { + switch (change.change) { + case "Grün": + green++; + break; + + case "Orange": + orange++; + break; + + case "Rot": + red++; + break; + + case "ObservationRequest": + openObservationRequest = change.date; + break; + + case "ObservationStart": + console.assert(openObservationRequest, "No request for observation start"); + observationPeriod = new ObservationPeriod(change.date); + openObservationRequest = null; + break; + + case "ObservationEnd": + console.assert(observationPeriod, "No active observation period"); + observationPeriod.finishPeriod(change.context.success); + if (change.context.success) { + level++; + } + pastObservations.push(observationPeriod); + observationPeriod = null; + break; + + case "Observation": + console.assert(observationPeriod, "No active observation period"); + const observation = Observation.fromChange(change); + observationPeriod.addObservation(observation); + break; + + case "LevelChange": + level = change.context.level; + break; + + default: + alert(`Unexpected change: ${change}`); + } + } + return new Graduation(student, green, orange, red, level, + stickers, + openObservationRequest, observationPeriod, pastObservations, + changes); + } + + addChange(change) { + // TODO(muhq): Implement incremental addition + let changes = this.changes; + changes.push(change); + // This updates the student.graduation reference as well + Graduation.from(this.student, changes); + } + + /** + * Request observation + */ + async requestObservation() { + const request = GraduationChange.newObservationRequest(); + this.observationRequested = request.date; + await addEntryToStudentDatabase(this.student, "submit", JSON.stringify(request.publicDBFields())); + } + + /** + * Start an observation + */ + async startObservation() { + if (!this.observationRequested) { + console.assert(this.observationRequested, "No observation requested"); + return; + } + this.observationRequested = null; + this.observation = new ObservationPeriod(new Date()); + return submitGraduationChange(this.student, GraduationChange.newObservationStart()); + } + + /** + * End an observation + */ + async endObservation(reason, success) { + if (!this.observation) { + console.assert(this.observation, "No active observation"); + return; + } + this.observation.end = new Date(); + this.pastObservations.push(this.observation); + this.observation = null; + return submitGraduationChange(this.student, GraduationChange.newObservationEnd(reason, success)); + } + + /** + * Return if the student is in observation. + */ + isObserved() { + return Boolean(this.observation); + } + + /** + * Return the URI of the icon representing the graduation level. + */ + getIcon() { + return LEVEL_IMAGES[this.level - 1]; + } + + /** + * Return the name of the current graduation level. + * @return {String} The name of the current level. + */ + getLevelName() { + return LEVEL_NAMES[this.level - 1]; + } +} + +class ObservationPeriod { + constructor(start) { + this.start = start; + this.observations = []; + } + + finishPeriod(success) { + this.end = new Date(); + this.success = success; + } + + ended() { + return Boolean(this.end); + } + + ready() { + if (this.observations.length < 10) + return false; + + // at least 10 observation on different days + let days = new Map(); + for (const observation of this.observations) { + const d = observation.date; + days.set(`${d.getFullYear()}${d.getMonth()}${d.getDate()}`, true); + } + return days.size >= 10; + } + + getObservationsSums() { + let sums = Array(OBSERVATION_TYPES.length).fill().map(() => ({"yes":0, "no":0})); + for (const observation of this.observations) { + for (let i = 0; i < sums.length; i++) { + if (observation.status[i]) { + if (observation.status[i] === "yes") + sums[i].yes++; + else + sums[i].no++; + } + } + } + return sums; + } + + addObservation(observation) { + this.observations.push(observation); + } +} + +const OBSERVATION_TYPES = [ + "Arbeitet konzentriert an Aufgaben", + "Arbeitet zunehmend selbstständig", + "Führt das Logbuch zuverlässig", + "Bringt Material mit", + "Hält Regeln ein", + "Geht verantwortungsvoll mit Materialien und LernRaum um", + "Nimmt Rückmeldungen an und setzt sie um", +]; + +class Observation { + constructor(date, status) { + console.assert(status.length == OBSERVATION_TYPES.length, "invalid observation status"); + this.date = date; + this.status = status; + } + + static fromChange(change) { + return new Observation(change.date, JSON.parse(change.context)) + } +} + +function changesFromDbEntries(entries) { + let changes = []; + for (const e of entries) { + const gradChange = GraduationChange.fromDBEntry(e); + changes.push(gradChange); + } + return changes; +} + +async function fetchGraduationChangesFromDb(student, type) { + const data = await loadFromStudentDatabase(student, type); + return changesFromDbEntries(data); +} + +const submitEntriesPromise = async function() { + const db = await getSubmitDb(); + const entries = await db.fetchEntries(); + return changesFromDbEntries(entries); +}(); + +async function addSubmittedChanges(student, changes) { + let submitEntries = await submitEntriesPromise; + for (const e of submitEntries.filter( (c) => c.coach === student.name )) { + changes.push(e); + } + return changes; +} + +async function fetchGraduationChanges(student) { + let changes = await fetchGraduationChangesFromDb(student, "shadow"); + return addSubmittedChanges(student, changes); +} + +async function fetchPublicGraduationChanges(student) { + let changes = await fetchGraduationChangesFromDb(student, "public"); + return addSubmittedChanges(student, changes); +} + +async function fetchGraduation(student) { + const changes = await fetchGraduationChanges(student); + return Graduation.from(student, changes); +} + +async function fetchPublicGraduation(student) { + const changes = await fetchPublicGraduationChanges(student); + return Graduation.from(student, changes); +} + +async function submitGraduationChange(student, change) { + student.graduation.addChange(change); + + // Submit changes concurrently + let promises = []; + + if (change.isPublic()) { + promises.push( + // TODO(muhq): strip non public fields from changes + addEntryToStudentDatabase(student, "public", JSON.stringify(change.publicDBFields())) + ); + } + + promises.push( + addEntryToStudentDatabase(student, "shadow", JSON.stringify(change.dbFields())) + ); + + await Promise.all(promises); +} + +async function submitObservation(student, observation) { + console.assert(student.graduation.observation, "no active observation"); + student.graduation.observation.addObservation(observation); + await submitGraduationChange(student, GraduationChange.newObservationChange(observation)); +} + +export { + LEVEL_NAMES, + LEVEL_IMAGES, + GraduationChange, + Graduation, + fetchGraduation, + fetchPublicGraduation, + fetchGraduationChanges, + fetchPublicGraduationChanges, + submitGraduationChange, + submitObservation, + + OBSERVATION_TYPES, + Observation, + ObservationPeriod, +}; diff --git a/src/graduation-mock.js b/src/graduation-mock.js new file mode 100644 index 0000000..1c84433 --- /dev/null +++ b/src/graduation-mock.js @@ -0,0 +1,30 @@ +import { + LEVEL_NAMES, + GraduationChange, + Graduation, + + OBSERVATION_TYPES, + Observation, + ObservationPeriod, +} from "./graduation-common.js"; + +import { + fetchGraduationMock as fetchGraduation, + fetchPublicGraduationMock as fetchPublicGraduation, + submitGraduationChangeMock as submitGraduationChange, + submitObservationMock as submitObservation +} from "./mock.js"; + +export { + LEVEL_NAMES, + GraduationChange, + Graduation, + fetchGraduation, + fetchPublicGraduation, + submitGraduationChange, + submitObservation, + + OBSERVATION_TYPES, + Observation, + ObservationPeriod, +}; diff --git a/src/graduation-prod.js b/src/graduation-prod.js new file mode 100644 index 0000000..0e86956 --- /dev/null +++ b/src/graduation-prod.js @@ -0,0 +1,15 @@ +export { + LEVEL_NAMES, + GraduationChange, + Graduation, + fetchGraduation, + fetchPublicGraduation, + fetchGraduationChanges, + fetchPublicGraduationChanges, + submitGraduationChange, + submitObservation, + + OBSERVATION_TYPES, + Observation, + ObservationPeriod, +} from "./graduation-common.js"; diff --git a/src/group.js b/src/group.js new file mode 100644 index 0000000..ba5e1a0 --- /dev/null +++ b/src/group.js @@ -0,0 +1,57 @@ +import { getCourseParticipantsUrl } from "./utils-ENV.js"; +import { fetchStudents } from "./student-ENV.js"; + +/** + * Class to represent a Group of Students. + */ +class Group { + constructor(name, students) { + this.name = name; + if (!students) { + this.students = []; + } + } + + setStudents(students) { + this.students = students; + } + + addStudent(student) { + this.students.push(student); + } + + async getStudents() { + return this.students; + } +} + +let groups = new Map(); + +function buildGroups(students) { + for (const student of students) { + let group = groups.get(student.group); + + if (!group) { + group = new Group(student.group); + groups.set(student.group, group); + } + + group.addStudent(student); + } +} + +/** + * Fetch all Groups and return them as a map. + */ +async function fetchGroups() { + if (groups.size) { + return groups; + } + + // Fetch all students and build the groups from them + const students = await fetchStudents(); + buildGroups(students); + return groups; +} + +export { Group, buildGroups, fetchGroups }; diff --git a/src/mass-graduation.js b/src/mass-graduation.js new file mode 100644 index 0000000..3cbf32a --- /dev/null +++ b/src/mass-graduation.js @@ -0,0 +1,357 @@ +import { + OBSERVATION_TYPES, + GraduationChange, + submitGraduationChange, + Observation, + submitObservation +} from "./graduation-ENV.js"; + +import { fetchGroups } from "./group.js"; +import { initSelection } from "./selection.js"; +import { getName, DATETIME_FMT, datetimeStringNow } from "./utils-ENV.js"; + +let group; +let name; + +function toggle(btn, others) { + if (btn.innerHTML === "X") { + btn.innerHTML = "o"; + } else { + btn.innerHTML = "X"; + if (others) { + for (const other of others) + other.innerHTML = "o"; + } + } + + document.getElementById("submit-mass-graduation").style.display = "inline"; + document.getElementById("submit-mass-graduation-state").innerHTML = ""; +} + +function observationClickHandler(li) { + let observationDiv = li.observationDiv; + // Build the observation container + if(!observationDiv) { + li.style.background = "lightgray"; + + observationDiv = document.createElement("div"); + // Remember the observation div for this student + li.observationDiv = observationDiv; + observationDiv.id = `${li.student.name}-observation-container`; + observationDiv.classList.add("group-member-observation-container"); + li.appendChild(observationDiv); + + const obsTbl = document.createElement("table"); + obsTbl.classList.add("group-member-observation-table"); + observationDiv.appendChild(obsTbl); + + const obsTh = document.createElement("thead"); + obsTh.innerHTML = ` + + + Ja + Nein + Kann ich nicht beurteilen +`; + + obsTbl.appendChild(obsTh); + + const obsTbdy = document.createElement("tbody"); + obsTbl.appendChild(obsTbdy); + + for (const obsType of OBSERVATION_TYPES) { + const obsTr = document.createElement("tr"); + + const obsDesc = document.createElement("td"); + obsDesc.innerHTML = obsType; + obsDesc.classList.add("group-member-observation-table-description"); + obsTr.appendChild(obsDesc); + + const obsYes = document.createElement("td"); + obsYes.innerHTML = "o"; + obsYes.dataset.yes = "true"; + obsYes.classList.add("group-member-observation-table-yes"); + obsTr.appendChild(obsYes); + + const obsNo = document.createElement("td"); + obsNo.innerHTML = "o"; + obsNo.dataset.no = "true"; + obsNo.classList.add("group-member-observation-table-no"); + obsTr.appendChild(obsNo); + + const obsMaybe = document.createElement("td"); + obsMaybe.innerHTML = "X"; + obsMaybe.dataset.maybe = "true"; + obsMaybe.classList.add("group-member-observation-table-maybe"); + obsTr.appendChild(obsMaybe); + + obsYes.addEventListener("click", () => { + toggle(obsYes, [obsNo, obsMaybe]); + }); + obsNo.addEventListener("click", () => { + toggle(obsNo, [obsYes, obsMaybe]); + }); + obsMaybe.addEventListener("click", () => { + toggle(obsMaybe, [obsYes, obsNo]); + }); + + obsTbdy.appendChild(obsTr); + } + // Toggle the observation container visibility + } else { + if (observationDiv.style.display === "none") { + observationDiv.style.display = "block"; + li.style.background = "lightgray"; + + } else { + observationDiv.style.display = "none"; + li.style.background = "unset"; + } + } +} + +async function loadGroup(selected) { + group = selected; + const students = await group.getStudents(); + let graduationPromises = []; + document.getElementById("group-content").style.display = "block"; + + const ul = document.getElementById("group-members"); + // Clear group list + ul.innerHTML = ""; + + for (const student of students) { + const graduationPromise = student.getGraduation(); + + const li = document.createElement("li"); + li.classList.add("group-member") + // Remember the student of the entry + li.student = student; + + const i = document.createElement("img"); + i.classList.add("group-member-icon") + graduationPromise.then((graduation) => { + i.src = graduation.getIcon(); + }); + li.appendChild(i); + + const n = document.createElement("span"); + n.classList.add("group-member-name") + n.innerHTML = `${student.name}`; + li.appendChild(n); + + const ob = document.createElement("button"); + ob.classList.add("group-member-observation-icon") + + graduationPromise.then((graduation) => { + if (!graduation.isObserved()) { + return; + } + ob.innerHTML = "👁" + ob.addEventListener("click", () => observationClickHandler(li) ); + }); + graduationPromises.push(graduationPromise); + + li.appendChild(ob); + + for (const col of ["green", "orange", "red"]) { + const b = document.createElement("button"); + b.classList.add(`group-member-${col}`) + b.innerHTML = "o"; + b.addEventListener("click", () => toggle(b) ); + li.appendChild(b); + } + + const textarea = document.createElement("textarea"); + textarea.classList.add("group-member-point-reason"); + li.appendChild(textarea); + + const p = document.createElement("button"); + p.classList.add("group-member-print") + p.innerHTML = "🖶"; + p.addEventListener("click", () => printNote(li) ); + li.appendChild(p); + + ul.appendChild(li); + } + + const graduations = await Promise.all(graduationPromises); + let observations = graduations.filter( + (g) => g.observationRequested || (g.observation && !g.observation.end) + ); + + const observationReuqestsContainer = document.getElementById("observation-requests-container"); + + if (observations.length == 0) { + observationReuqestsContainer.style.display = "none"; + return; + } else { + observationReuqestsContainer.style.display = "unset"; + } + + const observationReuqestsNode = document.getElementById("observation-requests"); + observationReuqestsNode.innerHTML = ""; + observations.sort((g1, g2) => { + let g1D, g2D; + g1D = g1.observationRequested ? g1.observationRequested : g1.observation.start; + g2D = g2.observationRequested ? g2.observationRequested : g2.observation.start; + return g1D - g2D; + }); + + for (const graduation of observations) { + const row = document.createElement("tr"); + const iconCell = document.createElement("td"); + const icon = document.createElement("img"); + icon.src = graduation.getIcon(); + row.appendChild(iconCell); + + const nameCell = document.createElement("td"); + nameCell.innerHTML = `${graduation.student.name}`; + row.appendChild(nameCell); + + const dateCell = document.createElement("td"); + let date; + if (graduation.observationRequested) { + date = graduation.observationRequested; + } else { + date = graduation.observation.start; + } + dateCell.innerHTML = DATETIME_FMT.format(date); + row.appendChild(dateCell); + + const btnCell = document.createElement("td"); + if (graduation.observation && !graduation.observation.ready()) { + btnCell.innerHTML = "In Beobachtungsphase"; + } else { + const startBtn = document.createElement("button"); + if (graduation.observationRequested) { + startBtn.addEventListener("click", () => { + graduation.startObservation(); + //TODO(muhq): Add observation icon-button. + startBtn.remove(); + btnCell.innerHTML = "In Beobachtungsphase"; + }); + startBtn.innerHTML = `Beobachtungsphase beginnen`; + startBtn.classList.add("start-observation"); + } else { + startBtn.addEventListener("click", () => { + window.open(`/mod/page/view.php?id=93017047&userToLoad=${graduation.student.id}`, '_blank'); + }); + startBtn.innerHTML = `Graduierung abschließen`; + startBtn.classList.add("end-observation"); + } + btnCell.appendChild(startBtn); + } + row.appendChild(btnCell); + + observationReuqestsNode.appendChild(row); + } +} + +function submitMassGraduation() { + const state = document.getElementById("submit-mass-graduation-state"); + state.innerHTML = ""; + state.classList.add("submit-mass-graduation-inprogress"); + state.style.display = "unset"; + + // TODO(muhq): await graduation promises + + let promises = []; + for(const li of document.querySelectorAll("li.group-member")) { + const reason = li.querySelector(".group-member-point-reason").value; + const student = li.student; + if (li.querySelector(".group-member-green").innerHTML === "X") { + promises.push(submitGraduationChange(student, GraduationChange.newGreenPointChange(reason, name))); + } + if (li.querySelector(".group-member-orange").innerHTML === "X") { + promises.push(submitGraduationChange(student, GraduationChange.newOrangePointChange(reason, name))); + } + if (li.querySelector(".group-member-red").innerHTML === "X") { + promises.push(submitGraduationChange(student, GraduationChange.newRedPointChange(reason, name))); + } + if (li.observationDiv) { + let status = []; + for (const row of li.observationDiv.querySelectorAll("tbody > tr")) { + if (row.querySelector('td[data-yes="true"]').innerHTML === "X") + status.push("yes"); + else if (row.querySelector('td[data-no="true"]').innerHTML === "X") + status.push("no"); + else + status.push(""); + } + promises.push(submitObservation(student, new Observation(new Date(), status))); + } + } + + Promise.all(promises).then(() => { + state.style.display = "inline"; + state.innerHTML = "✓"; + state.style.color = "green"; + for (const li of document.querySelectorAll("li.group-member")) { + if (!li.observationDiv) + continue; + li.observationDiv.remove(); + li.observationDiv = null; + } + }).catch((error) => { + state.style.display = "inline"; + state.innerHTML = "X"; + state.style.color = "red"; + console.log(error); + }).finally(() => { + state.classList.remove("submit-mass-graduation-inprogress"); + }); +} + +let prePrintBody = null; +function printNote(li) { + // save the current DOM + prePrintBody = document.body; + + // prepare the printable red point notice + const printBdy = document.createElement("body"); + const reason = li.querySelector(".group-member-point-reason").value; + const dateStr = datetimeStringNow(); + let pointType = ""; + if (li.querySelector(".group-member-orange").innerHTML === "X") { + pointType = "Oranger Punkt"; + } + if (li.querySelector(".group-member-red").innerHTML === "X") { + pointType = "Roter Punkt"; + } + + printBdy.innerHTML = ` +

Dokuzettel

+

${pointType}

+

Schüler*in: ${li.student.name}

+

Datum: ${dateStr}

+

Lehrkraft: ${name}

+

Klassenleitung: TODO

+

Begründung

+

${reason}

+`; + + document.body = printBdy; + print(); + // Restore the DOM after printing the red point note + document.body = prePrintBody; +} + +async function init() { + document.getElementById("submit-mass-graduation").addEventListener("click", submitMassGraduation); + name = getName(); + + initSelection( + "group-selection", + async function() { return (await fetchGroups()).values(); }, + (group) => `${group.name}`, + loadGroup, + ); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} + diff --git a/src/mock.js b/src/mock.js new file mode 100644 index 0000000..91b1525 --- /dev/null +++ b/src/mock.js @@ -0,0 +1,104 @@ +import { Graduation, GraduationChange } from "./graduation-common.js"; +import { Student } from "./student-common.js"; +import { buildGroups } from "./group.js"; + +const students = [ + new Student(1, 'foo bar', 'test'), + new Student(2, 'maxi mustermensch', 'test'), + new Student(3, 'b b b', '5a') +]; + +// Build the groups from our mock students +buildGroups(students); + +function fetchStudentsMock() { + return new Promise((resolve) => { resolve(students); }); +} + +function fetchSubjectsMock() { + return new Promise((resolve) => { + resolve(['Mathe', 'Deutsch', 'Englisch']); + }); +} + +function fetchProgressMock(subject) { + return new Promise((resolve) => { + if (subject === "Mathe") { + resolve([40, 10, 3]); + } else if (subject === "Deutsch") { + resolve([10, 2, 0]); + } else if (subject === "Englisch") { + resolve([50, 25, 10]); + } else { + resolve([0, 0, 0]); + } + }); +} + +const graduationChanges = {}; +for (const s of students) { + switch (s.name) { + case "foo bar": + graduationChanges[s.name] = [ + GraduationChange.newGreenPointChange("Gute Arbeit", "Florian Fischer"), + GraduationChange.newOrangePointChange("", "Florian Fischer"), + ]; + break; + case "maxi mustermensch": + graduationChanges[s.name] = [ + GraduationChange.newGreenPointChange("", "Florian Fischer"), + GraduationChange.newGreenPointChange("", "Florian Fischer"), + new GraduationChange(new Date(), "ObservationStart", "", "Florian Fischer", ""), + ]; + break; + case "b b b": + graduationChanges[s.name] = [ + GraduationChange.newRedPointChange("", "Florian Fischer"), + ]; + } + // Build the student's graduation + Graduation.from(s, graduationChanges[s.name]); +} + +function fetchGraduationMock(s) { + return new Promise((resolve) => { + const changes = graduationChanges[s.name]; + if (!changes) { + resolve(null); + } else { + resolve(Graduation.from(s, changes)); + } + }); +} + +function fetchPublicGraduationMock(s) { + return new Promise((resolve) => { + const changes = graduationChanges[s.name]; + if (!changes) { + resolve(null); + } else { + resolve(Graduation.from(s, changes.filter((change) => change.isPublic()))); + } + }); +} + +function submitGraduationChangeMock(s, c) { + console.assert(s.graduation, "no graduation") + s.graduation.addChange(c); +} + +function submitObservationMock(s, o) { + console.assert(s.graduation, "no graduation") + console.assert(s.graduation.observation, "no observation period") + s.graduation.observation.addObservation(o); +} + +export { + fetchStudentsMock, + fetchSubjectsMock, + fetchProgressMock, + fetchGraduationMock, + fetchPublicGraduationMock, + submitGraduationChangeMock, + submitObservationMock, +}; diff --git a/src/selection.js b/src/selection.js new file mode 100644 index 0000000..eda20ae --- /dev/null +++ b/src/selection.js @@ -0,0 +1,170 @@ +/** + * Initialises a selection based on existing HTML elements. + * For HTML elements are required: + * - A text input with id $idPrefix + * - A list with id ${idPrefix}-list + * - An element shown if no results are available with id ${idPrefix}-noresults + * - A button to clear the input with id ${idPrefix}-clear + * + * @param {string} idPrefix - The id prefix of the four HTML elements. + * @param {async function} fetchCandidates - The async function to retrieve the selectable candidates. + * @param {function} fmtCandidate - The function to format the displayed HTML for each candidate. + * @param {function} onSelection - The function to call when a candidate is selected. + */ +async function initSelection(idPrefix, fetchCandidates, fmtCandidate, onSelection) { + const sel = document.getElementById(idPrefix); + const list = document.getElementById(`${idPrefix}-list`); + const noResults = document.getElementById(`${idPrefix}-noresults`); + const clearBtn = document.getElementById(`${idPrefix}-clear`); + const loader = document.getElementById(`${idPrefix}-loader`); + + loader.style.display = "unset"; + const candidates = await fetchCandidates(); + loader.style.display = "none"; + + let items = []; + + let currentFocus = -1; + let visibleItems = []; + function setActive() { + visibleItems.forEach(item => item.classList.remove("active")); + if (currentFocus >= 0 && visibleItems[currentFocus]) { + visibleItems[currentFocus].classList.add("active"); + } + } + + for (const candidate of candidates) { + let item = document.createElement("li"); + item.candidate = candidate; + item.innerHTML = fmtCandidate(candidate); + item.style.display = 'none'; + + item.addEventListener('click', function() { + sel.value = ""; + sel.dispatchEvent(new Event('keyup')); + onSelection(candidate); + }); + + list.appendChild(item); + items.push(item); + } + + sel.addEventListener('keyup', function(e) { + if ((["ArrowDown", "ArrowUp", "Enter"]).includes(e.key)) + return; + + const filter = sel.value.toLowerCase(); + visibleItems = []; + for (const item of items) { + const itemText = item.textContent.trim().toLowerCase(); + + if (itemText.indexOf(filter) > -1 && filter !== "") { + item.style.display = ""; + visibleItems.push(item); + + // Highlight matched text + const regex = new RegExp(`(${filter})`, 'gi'); + item.innerHTML = item.textContent.replace( + regex, + "$1" + ); + } else { + item.style.display = "none"; + } + } + + // Show the clear button if there is an input + clearBtn.style.display = sel.value ? "block" : "none"; + + // Select the first or none if there are no items visible + currentFocus = visibleItems.length ? 0 : -1; + setActive(visibleItems); + noResults.style.display = visibleItems.length || filter === "" ? "none" : "block"; + }); + + clearBtn.addEventListener('click', function() { + sel.value = ""; + sel.dispatchEvent(new Event('keyup')); // reset list + sel.focus(); + }); + + sel.addEventListener('keydown', function(e) { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + if(e.key === "ArrowDown") + currentFocus = (currentFocus + 1) % visibleItems.length; + else + currentFocus = (currentFocus - 1 + visibleItems.length) % visibleItems.length; + setActive(); + e.preventDefault(); + } else if (e.key === "Enter") { + if (currentFocus > -1 && visibleItems[currentFocus]) { + + const visibleItem = visibleItems[currentFocus]; + sel.value = ""; + sel.dispatchEvent(new Event('keyup')); + onSelection(visibleItem.candidate); + } + e.preventDefault(); + } + }); +} + +function pointClickHandler(color) { + const textareaDiv = document.getElementById("student-info-point-reason-container"); + const textarea = document.getElementById("student-info-point-reason"); + const submitBtn = document.getElementById("student-info-point-reason-submit"); + + // TODO(muhq): Prefill the textarea with common reasons + switch (color) { + case "green": + textarea.style.background = "rgb(from green r g b / 20%)" + textarea.value = ""; + break; + case "orange": + textarea.style.background = "rgb(from orange r g b / 20%)" + textarea.value = ""; + break; + case "red": + textarea.style.background = "rgb(from red r g b / 20%)" + textarea.value = ""; + break; + } + textareaDiv.style.display = "block"; + textarea.focus(); + + // Can not use string interpolation because of Moodle's firstname filter. + const coach = firstname + " " + lastname; + + let submit = function() { + let change; + switch (color) { + case "green": + change = GraduationChange.newGreenPointChange(student, textarea.value, coach); + break; + case "orange": + change = GraduationChange.newOrangePointChange(student, textarea.value, coach); + break; + case "red": + change = GraduationChange.newRedPointChange(student, textarea.value, coach); + break; + default: + alert("Invalid color"); + break; + } + submitGraduationChange(student, change); + textareaDiv.style.display = "none"; + updateStudentInfo(); + } + // Use onEVENT to override any previous handler + textarea.onkeydown = function(e) { + if (e.shiftKey && e.key === "Enter") { + submit(); + } + if (e.key === "Escape") { + textareaDiv.style.display = "none"; + } + }; + submitBtn.onclick = submit; +} + +export { initSelection }; diff --git a/src/student-common.js b/src/student-common.js new file mode 100644 index 0000000..2dfa35d --- /dev/null +++ b/src/student-common.js @@ -0,0 +1,115 @@ +import { getCourseId, getUserIconUrl, getCourseParticipantsUrl } from "./utils-ENV.js"; +import { fetchGraduation } from "./graduation-common.js"; +/** + * Class to represent Students. + */ +class Student { + graduation = null; + + constructor(id, name, group) { + this.id = id; + this.name = name; + this.group = group; + } + + async getIconUrl() { + return await getUserIconUrl(this.id); + } + + getGroup() { + return this.group; + } + + async getGraduation() { + if(!this.graduation) { + // fetchGraduation(this) sets this.graduation + await fetchGraduation(this); + } + return this.graduation; + } + + static async fetchById(id) { + let name = ""; + let group = ""; + + const res = await fetch(`/user/view.php?id=${id}&course=${getCourseId()}`); + const doc = new DOMParser().parseFromString(await res.text(), "text/html"); + + name = doc.querySelector("div.page-header-headings > h2").textContent; + + const cards = doc.querySelectorAll("div.card-body dl"); + for (const card of cards) { + if (card.querySelector("dt").textContent !== "Gruppe") + continue; + group = card.querySelector("dd > a").textContent; + break; + } + + return new Student(id, name, group); + } +} + +// Cache the fetched students. +let students = []; + +const idRegex = /id=(\d+)/; +async function _fetchStudents() { + const res = await fetch(getCourseParticipantsUrl()); + const doc = new DOMParser().parseFromString(await res.text(), "text/html"); + const table = doc.querySelector("tbody"); + let fetchedStudents = []; + table.querySelectorAll("tr").forEach(row => { + if (row.classList.contains("emptyrow")) + return; + + const roleCell = row.querySelector(".c4"); + // TODO(muhq): Is this locale dependent? + if (roleCell.innerHTML.includes("Lehrer")) { + return; + } + + const nameCell = row.querySelector(".c1"); + let name; + if (nameCell.firstChild.firstChild.tagName == "SPAN") + name = nameCell.firstChild.firstChild.title; + else + name = nameCell.innerText; + + const profileUrl = nameCell.querySelector("a.aabtn").href; + const match = idRegex.exec(profileUrl); + const id = match[1]; + + const groupCell = row.querySelector(".c5"); + const group = groupCell.querySelector("a.quickeditlink").textContent; + + fetchedStudents.push(new Student(id, name, group)); + }); + return fetchedStudents; +} + +async function fetchStudents(idFilter, nameFilter, groupFilter) { + if (!students.size) { + const studentsPromise = _fetchStudents() + const storedStudents = localStorage.getItem("students"); + if (storedStudents) { + studentsPromise.then((fetched) => localStorage.setItem("students", JSON.stringify(fetched))); + for (const o of JSON.parse(storedStudents)) { + students.push(new Student(o.id, o.name, o.group)); + } + } else { + students = await studentsPromise; + localStorage.setItem("students", JSON.stringify(students)); + } + } + + return students.filter((s) => (!idFilter || s.id === idFilter) && + (!nameFilter || s.name.includes(nameFilter)) && + (!groupFilter || s.group.includes(groupFilter))); +} + +async function fetchStudent(idFilter, nameFilter, groupFilter) { + const students = await fetchStudents(idFilter, nameFilter, groupFilter); + return students[0]; +} + +export { Student, fetchStudents, fetchStudent }; diff --git a/src/student-mock.js b/src/student-mock.js new file mode 100644 index 0000000..7c79e12 --- /dev/null +++ b/src/student-mock.js @@ -0,0 +1,9 @@ +import { Student } from "./student-common.js"; +import { fetchStudentsMock as fetchStudents } from "./mock.js"; + +async function fetchStudent(idFilter, nameFilter, groupFilter) { + const students = await fetchStudents(idFilter, nameFilter, groupFilter); + return students[0]; +} + +export { Student, fetchStudents, fetchStudent }; diff --git a/src/student-prod.js b/src/student-prod.js new file mode 100644 index 0000000..9c8d7cb --- /dev/null +++ b/src/student-prod.js @@ -0,0 +1,2 @@ +import { Student, fetchStudent, fetchStudents } from "./student-common.js"; +export { Student, fetchStudents, fetchStudent }; diff --git a/src/utils-common.ts b/src/utils-common.ts new file mode 100644 index 0000000..89a8ddd --- /dev/null +++ b/src/utils-common.ts @@ -0,0 +1,25 @@ +"use strict"; + +export const DATE_FMT = new Intl.DateTimeFormat("de-DE", { month: "numeric", day: "numeric", year: "numeric"}); + +export const DATETIME_FMT = new Intl.DateTimeFormat("de-DE", { month: "numeric", day: "numeric", year: "numeric", hour: "numeric", minute: "numeric" }); + +export function dateStringNow() { + return DATE_FMT.format(new Date()); +} + +export function datetimeStringNow() { + return DATETIME_FMT.format(new Date()); +} + +// TODO(muhq): Cache the retrieved URL +export async function getUserIconUrl(userId: number) { + const userRes = await fetch(`https://lernplattform.bycs.de/user/profile.php?id=${userId}`) + const userDoc = new DOMParser().parseFromString(await userRes.text(), 'text/html') + const userpicture = userDoc.querySelector(".userpicture"); + if (userpicture === null) { + return ""; + } + + return (userpicture as HTMLImageElement).src; +} diff --git a/src/utils-mock.js b/src/utils-mock.js new file mode 100644 index 0000000..e64b59d --- /dev/null +++ b/src/utils-mock.js @@ -0,0 +1,41 @@ +import { fetchStudent } from "./student-ENV.js"; +export { DATE_FMT, DATETIME_FMT, dateStringNow, datetimeStringNow } from "../dist/src/utils-common.js"; + +export function isTeacher() { + return !(new URLSearchParams(document.location.search)).has("student"); +} + +export function getFirstAndLastname() { + let firstname = ""; + let lastname = ""; + + const params = new URLSearchParams(document.location.search); + if (params.has("firstname")) + firstname = params.get("firstname") + if (params.has("lastname")) + lastname = params.get("lastname") + + return [firstname, lastname]; +} + +export function getName() { + let name = ""; + + const params = new URLSearchParams(document.location.search); + if (params.has("firstname")) + name = params.get("firstname") + if (params.has("lastname")) + name += params.get("lastname") + + return name; +} + +export function getUserId() { + const name = getName(); + + return fetchStudent(null, name).id; +} + +export function getCourseId() { + return "1337"; +} diff --git a/src/utils-prod.js b/src/utils-prod.js new file mode 100644 index 0000000..efcd8e3 --- /dev/null +++ b/src/utils-prod.js @@ -0,0 +1,36 @@ +export { DATE_FMT, DATETIME_FMT, dateStringNow, datetimeStringNow, getUserIconUrl } from "./utils-common.ts"; + +export function isTeacher() { + return Boolean(document.querySelector('.editmode-switch-form')) + || document.body.classList.contains('editing'); +} + +export function getFirstAndLastname() { + let firstname = ""; + let lastname = ""; + + const autoNameDiv = document.getElementById("auto-name"); + firstname = autoNameDiv.children[0].innerHTML; + lastname = autoNameDiv.children[1].innerHTML; + autoNameDiv.remove(); + + return [firstname, lastname]; +} + +export function getName() { + return document.querySelector("bycs-topbar").shadowRoot.querySelector("div.user-name").textContent; +} + +export function getUserId() { + // M is exported by Moodle + return String(M.cfg.userId); +} + +export function getCourseId() { + // M is exported by Moodle + return String(M.cfg.courseId); +} + +export function getCourseParticipantsUrl() { + return `https://lernplattform.bycs.de/user/index.php?id=${getCourseId()}&perpage=5000`; +} -- cgit v1.2.3