aboutsummaryrefslogtreecommitdiff
path: root/js
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2026-06-13 13:04:07 +0200
committerFlorian Fischer <florian.fischer@muhq.space>2026-06-13 13:04:07 +0200
commit4882c5ca3a65075e03595588ef3780a36bc74b15 (patch)
tree07aa2a276cd87a8e192147440411c1f51d8aa624 /js
parent9fbd21ff6bf97b9ce77cb78f420abd951b8aaf27 (diff)
downloadbycs-sol-4882c5ca3a65075e03595588ef3780a36bc74b15.tar.gz
bycs-sol-4882c5ca3a65075e03595588ef3780a36bc74b15.zip
move generic js into root
Diffstat (limited to 'js')
-rw-r--r--js/config.js42
-rw-r--r--js/database.js48
-rw-r--r--js/graduation-common.js133
-rw-r--r--js/graduation-mock.js6
-rw-r--r--js/graduation-prod.js2
-rw-r--r--js/mock.js77
-rw-r--r--js/selection.js167
-rw-r--r--js/student-common.js73
-rw-r--r--js/student-mock.js3
-rw-r--r--js/student-prod.js2
-rw-r--r--js/utils-mock.js3
-rw-r--r--js/utils-prod.js5
12 files changed, 561 insertions, 0 deletions
diff --git a/js/config.js b/js/config.js
new file mode 100644
index 0000000..2beb91b
--- /dev/null
+++ b/js/config.js
@@ -0,0 +1,42 @@
+const GRADUATION_DB_ID = 262744;
+const GRADUATION_ACTIVITY_ID = 92663494;
+const GRADUATION_DB_PARAMS_MAP = {
+ id: "f_1133275",
+ name: "f_1130848",
+ group: "f_1130869",
+ change: "f_1133254",
+ reason: "f_1130860",
+ date: (date) => {
+ return {
+ f_1130863_d: date.getDate(),
+ f_1130863_m: date.getMonth(),
+ f_1130863_y: date.getFullYear(),
+ f_1130863_z: 1,
+ };
+ },
+ coach: "f_1130866",
+}
+
+const courseId = 2466361;
+function getCourseId() {
+ return courseId;
+}
+
+function getCourseParticipantsUrl() {
+ return `https://lernplattform.bycs.de/user/index.php?id=${courseId}`;
+}
+
+async function getUserIconUrl(userId) {
+ console.assert(userId, "getUserIconUrl needs a userId");
+ 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");
+ return userpicture ? userpicture.src : "";
+}
+
+export {
+ GRADUATION_DB_ID, GRADUATION_ACTIVITY_ID, GRADUATION_DB_PARAMS_MAP,
+ getCourseId,
+ getUserIconUrl,
+ getCourseParticipantsUrl
+};
diff --git a/js/database.js b/js/database.js
new file mode 100644
index 0000000..3297a11
--- /dev/null
+++ b/js/database.js
@@ -0,0 +1,48 @@
+async function loadFromDatabase(id, params) {
+ let dbUrl = `/mod/data/view.php?d=${id}`;
+ dbUrl += "&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;
+ const value = field.innerText.trim();
+ entry[key] = value;
+ });
+ data.push(entry);
+ });
+ return data;
+}
+
+async function addEntryToDatabaseActivity(id, params) {
+ 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 in params) {
+ const formKey = key.replace("f_", "field_");
+ formData.set(formKey, params[key]);
+ }
+ formData.set("saveandview", "Speichern");
+
+ return await fetch("/mod/data/edit.php", {
+ method: "POST",
+ // Set the FormData instance as the request body
+ body: formData,
+ });
+}
+
+export { loadFromDatabase, addEntryToDatabaseActivity };
diff --git a/js/graduation-common.js b/js/graduation-common.js
new file mode 100644
index 0000000..4716e6d
--- /dev/null
+++ b/js/graduation-common.js
@@ -0,0 +1,133 @@
+import { GRADUATION_DB_ID, GRADUATION_ACTIVITY_ID, GRADUATION_DB_PARAMS_MAP } from "./config.js";
+import { loadFromDatabase, addEntryToDatabaseActivity } from "./database.js"
+
+class GraduationChange {
+ constructor(date, id, name, group, change, reason, coach, json) {
+ this.date = date;
+ this.id = id;
+ this.name = name;
+ this.group = group;
+ this.change = change;
+ this.reason = reason;
+ this.coach = coach;
+ this.json = json;
+ }
+
+ static DB_FIELDS = ["id", "name", "group", "change", "reason", "json"];
+
+ static newGreenPointChange(student, reason, coach) {
+ return new GraduationChange(
+ new Date(),
+ student.id, student.name, student.group,
+ "Grün", reason, coach, ""
+ );
+ }
+
+ static newOrangePointChange(student, reason, coach) {
+ return new GraduationChange(
+ new Date(),
+ student.id, student.name, student.group,
+ "Orange", reason, coach, ""
+ );
+ }
+
+ static newRedPointChange(student, reason, coach) {
+ return new GraduationChange(
+ new Date(),
+ student.id, student.name, student.group,
+ "Rot", reason, coach, ""
+ );
+ }
+}
+
+class Graduation {
+ constructor(student, green, orange, red, level, stickers, changes) {
+ this.student = student;
+ this.green = green;
+ this.orange = orange;
+ this.red = red;
+ this.level = level;
+ this.stickers = stickers;
+ this.changes = changes;
+
+ student.graduation = this;
+ }
+
+ static from(student, changes) {
+ let green = 0;
+ let orange = 0;
+ let red = 0;
+ let level = "Beginner";
+ let stickers = [];
+ for (const change of changes) {
+ switch (change.change) {
+ case "Grün":
+ green++;
+ break;
+ case "Orange":
+ orange++;
+ break;
+ case "Rot":
+ red++;
+ break;
+ case "Gespräch":
+ orange = 0;
+ break;
+ case "Änderung":
+ // TODO:
+ break;
+ default:
+ alert(`Unexpected change: ${change}`);
+ }
+ }
+ return new Graduation(student, green, orange, red, level, stickers, 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);
+ }
+}
+
+async function fetchGraduationChanges(student) {
+ let params = {};
+ // The id is enough
+ params[GRADUATION_DB_PARAMS_MAP["id"]] = student.id;
+
+ const data = await loadFromDatabase(GRADUATION_DB_ID, params);
+
+ let changes = [];
+ for (const e of data) {
+ const gradChange = new GraduationChange(e.date, e.id, e.name, e.group, e.change, e.reason, e.coach, e.json);
+ changes.push(gradChange);
+ }
+ return changes;
+}
+
+async function fetchGraduation(student) {
+ const changes = await fetchGraduationChanges(student);
+ return Graduation.from(student, changes);
+}
+
+async function submitGraduationChange(student, change) {
+ let params = {};
+ student.graduation.addChange(change);
+ for (const key of GraduationChange.DB_FIELDS) {
+ if (GRADUATION_DB_PARAMS_MAP[key]) {
+ params[GRADUATION_DB_PARAMS_MAP[key]] = change[key];
+ }
+ }
+
+ addEntryToDatabaseActivity(GRADUATION_ACTIVITY_ID, params);
+}
+
+export {
+ GraduationChange,
+ Graduation,
+ fetchGraduation,
+ fetchGraduationChanges,
+ submitGraduationChange
+}; \ No newline at end of file
diff --git a/js/graduation-mock.js b/js/graduation-mock.js
new file mode 100644
index 0000000..9f67185
--- /dev/null
+++ b/js/graduation-mock.js
@@ -0,0 +1,6 @@
+import { Graduation, GraduationChange, } from "./graduation-common.js";
+import {
+ fetchGraduationMock as fetchGraduation,
+ submitGraduationChangeMock as submitGraduationChange
+} from "./mock.js";
+export { GraduationChange, Graduation, fetchGraduation, submitGraduationChange}; \ No newline at end of file
diff --git a/js/graduation-prod.js b/js/graduation-prod.js
new file mode 100644
index 0000000..de7cbf4
--- /dev/null
+++ b/js/graduation-prod.js
@@ -0,0 +1,2 @@
+import { GraduationChange, Graduation, fetchGraduation, submitGraduationChange } from "./graduation-common.js";
+export { GraduationChange, Graduation, fetchGraduation, submitGraduationChange }; \ No newline at end of file
diff --git a/js/mock.js b/js/mock.js
new file mode 100644
index 0000000..a189a11
--- /dev/null
+++ b/js/mock.js
@@ -0,0 +1,77 @@
+import { Graduation, GraduationChange } from "./graduation-common.js";
+import { Student } from "./student-common.js";
+
+const students = [
+ new Student(1, 'foo bar', 'test'),
+ new Student(2, 'maxi mustermensch', 'test'),
+ new Student(3, 'b b b', '5a')
+];
+
+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(s, "", "Florian Fischer"),
+ GraduationChange.newOrangePointChange(s, "", "Florian Fischer"),
+ ];
+ break;
+ case "maxi mustermensch":
+ graduationChanges[s.name] = [
+ GraduationChange.newGreenPointChange(s, "", "Florian Fischer"),
+ GraduationChange.newGreenPointChange(s, "", "Florian Fischer"),
+ ];
+ break;
+ case "b b b":
+ graduationChanges[s.name] = [
+ GraduationChange.newRedPointChange(s, "", "Florian Fischer"),
+ ];
+ }
+}
+
+function fetchGraduationMock(s) {
+ return new Promise((resolve) => {
+ const changes = graduationChanges[s.name];
+ if (!changes) {
+ resolve(null);
+ } else {
+ resolve(Graduation.from(s, graduationChanges[s.name]));
+ }
+ });
+}
+
+function submitGraduationChangeMock(s, c) {
+ s.graduation.addChange(c);
+}
+
+export {
+ fetchStudentsMock,
+ fetchSubjectsMock,
+ fetchProgressMock,
+ fetchGraduationMock,
+ submitGraduationChangeMock,
+};
diff --git a/js/selection.js b/js/selection.js
new file mode 100644
index 0000000..a1da244
--- /dev/null
+++ b/js/selection.js
@@ -0,0 +1,167 @@
+/**
+ * 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 candidates = await fetchCandidates();
+
+ 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,
+ "<u class='student-selection-list-highlight'>$1</u>"
+ );
+ } 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/js/student-common.js b/js/student-common.js
new file mode 100644
index 0000000..930c373
--- /dev/null
+++ b/js/student-common.js
@@ -0,0 +1,73 @@
+import { getUserIconUrl, getCourseParticipantsUrl } from "./config.js";
+/**
+ * Class to represent Students.
+ */
+class Student {
+ constructor(id, name, groups) {
+ this.id = id;
+ this.name = name;
+ this.groups = groups;
+ }
+
+ async getIconUrl() {
+ return await getUserIconUrl(this.id);
+ }
+
+ getGroupName() {
+ return `g${this.id}${this.name.replace(' ', '')}`;
+ }
+}
+
+async function fetchStudent(idFilter, nameFilter, groupFilter) {
+ const students = await fetchStudents(idFilter, nameFilter, groupFilter);
+ return students[0];
+}
+
+// Cache the fetched students.
+let students = [];
+
+const idRegex = /id=(\d+)/;
+async function fetchStudents(idFilter, nameFilter, groupFilter) {
+ if (students.size) {
+ return students;
+ }
+
+ const res = await fetch(getCourseParticipantsUrl());
+ const doc = new DOMParser().parseFromString(await res.text(), "text/html");
+ const table = doc.querySelector("tbody");
+ 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 groupsText = groupCell.querySelector("a.quickeditlink").textContent;
+ const group = groupsText.trim();
+
+ if ((!idFilter || id === idFilter) &&
+ (!nameFilter || name.includes(nameFilter)) &&
+ (!groupFilter || groupFilter === group))
+ {
+ students.push(new Student(id, name, group));
+ }
+ });
+ return students;
+}
+
+export { Student, fetchStudents, fetchStudent };
diff --git a/js/student-mock.js b/js/student-mock.js
new file mode 100644
index 0000000..0ea12ea
--- /dev/null
+++ b/js/student-mock.js
@@ -0,0 +1,3 @@
+import { Student, fetchStudent } from "./student-common.js";
+import { fetchStudentsMock as fetchStudents } from "./mock.js";
+export { Student, fetchStudents, fetchStudent }; \ No newline at end of file
diff --git a/js/student-prod.js b/js/student-prod.js
new file mode 100644
index 0000000..f9c430c
--- /dev/null
+++ b/js/student-prod.js
@@ -0,0 +1,2 @@
+import { Student, fetchStudent, fetchStudents } from "./student-common.js";
+export { Student, fetchStudents, fetchStudent }; \ No newline at end of file
diff --git a/js/utils-mock.js b/js/utils-mock.js
new file mode 100644
index 0000000..4f5348d
--- /dev/null
+++ b/js/utils-mock.js
@@ -0,0 +1,3 @@
+export function isTeacher() {
+ return true;
+}
diff --git a/js/utils-prod.js b/js/utils-prod.js
new file mode 100644
index 0000000..1dd7e8e
--- /dev/null
+++ b/js/utils-prod.js
@@ -0,0 +1,5 @@
+export function isTeacher() {
+ return Boolean(document.querySelector('.editmode-switch-form'))
+ || Boolean(document.querySelector('a[href*="templates.php"]'))
+ || document.body.classList.contains('editing');
+}