diff options
Diffstat (limited to 'js')
| -rw-r--r-- | js/config.js | 42 | ||||
| -rw-r--r-- | js/database.js | 48 | ||||
| -rw-r--r-- | js/graduation-common.js | 133 | ||||
| -rw-r--r-- | js/graduation-mock.js | 6 | ||||
| -rw-r--r-- | js/graduation-prod.js | 2 | ||||
| -rw-r--r-- | js/mock.js | 77 | ||||
| -rw-r--r-- | js/selection.js | 167 | ||||
| -rw-r--r-- | js/student-common.js | 73 | ||||
| -rw-r--r-- | js/student-mock.js | 3 | ||||
| -rw-r--r-- | js/student-prod.js | 2 | ||||
| -rw-r--r-- | js/utils-mock.js | 3 | ||||
| -rw-r--r-- | js/utils-prod.js | 5 |
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'); +} |
