diff options
| author | Florian Fischer <florian.fischer@muhq.space> | 2026-08-05 11:51:51 +0200 |
|---|---|---|
| committer | Florian Fischer <florian.fischer@muhq.space> | 2026-08-05 11:51:51 +0200 |
| commit | 582df8885a3573a728394b473b826616f8969316 (patch) | |
| tree | 9c21db03f4fbfd9956ee398e0d9194ebfa5b3234 | |
| parent | 46ac53e11b49a7f9cdb92c3559b05f41ce90122f (diff) | |
| download | bycs-sol-582df8885a3573a728394b473b826616f8969316.tar.gz bycs-sol-582df8885a3573a728394b473b826616f8969316.zip | |
convert a lot of js to typescript and use a single graduation DB
| -rw-r--r-- | doc/graduation.md | 42 | ||||
| -rw-r--r-- | src/cockpit.js | 59 | ||||
| -rw-r--r-- | src/database-mock.ts | 30 | ||||
| -rw-r--r-- | src/database-prod.ts (renamed from src/database.js) | 104 | ||||
| -rw-r--r-- | src/graduation-common.js | 396 | ||||
| -rw-r--r-- | src/graduation-common.ts | 347 | ||||
| -rw-r--r-- | src/graduation-mock.ts (renamed from src/graduation-mock.js) | 6 | ||||
| -rw-r--r-- | src/graduation-prod.js | 15 | ||||
| -rw-r--r-- | src/graduation-prod.ts | 43 | ||||
| -rw-r--r-- | src/group-fetch.ts | 18 | ||||
| -rw-r--r-- | src/group.js | 57 | ||||
| -rw-r--r-- | src/group.ts | 45 | ||||
| -rw-r--r-- | src/mass-graduation.js | 4 | ||||
| -rw-r--r-- | src/mock.js | 104 | ||||
| -rw-r--r-- | src/mock.ts | 81 | ||||
| -rw-r--r-- | src/student-common.js | 115 | ||||
| -rw-r--r-- | src/student-common.ts | 42 | ||||
| -rw-r--r-- | src/student-mock.js | 9 | ||||
| -rw-r--r-- | src/student-mock.ts | 25 | ||||
| -rw-r--r-- | src/student-prod.js | 2 | ||||
| -rw-r--r-- | src/student-prod.ts | 90 | ||||
| -rw-r--r-- | src/untyped.d.ts | 4 | ||||
| -rw-r--r-- | src/utils-common.ts | 4 | ||||
| -rw-r--r-- | src/utils-mock.js | 27 | ||||
| -rw-r--r-- | src/utils-prod.js | 9 |
25 files changed, 827 insertions, 851 deletions
diff --git a/doc/graduation.md b/doc/graduation.md index 2466c44..9edc289 100644 --- a/doc/graduation.md +++ b/doc/graduation.md @@ -1,37 +1,10 @@ # Graduation -## Tables +## Table -There exist three different tables to hold all graduation information of a student. -One global submission table called `Submit` writable by all students and two individual ones per student. -The separation is needed for the different required permissions to access the stored data. -All individual tables use the following naming scheme `${StudentId}${Purpose}`. - -The section containing the student's tables is only available to teachers and the student. - -### Submit Table - -The submit table is writable by all students and is used to track observation requests from students. -It is configured to require approval to publish entries. -This ensures that entries are only readable by the submitter and the teachers. -Entries are simply never approved. - -### Public Table - -The public table tracks all graduation changes known by the student. -The included information are sufficient to render a student's profile. - -Changes can be submitted by any teacher. -Each change is valid until a subsequent change renders it obsolete. - -Changes include points and level changes. - -### Shadow Table - -The shadow table tracks **all** graduation changes for a student. - -Changes can be submitted by any teacher. -Each change is valid until a subsequent change renders it obsolete. +There exist a single table called `Graduation` to hold all graduation information. +A single table is used to reduce the amount of requests required to load the data. +It is important that only teachers are allowed to read from the table. ## Graduation Changes @@ -62,11 +35,12 @@ The author of the change and the creation time are tracked by Moodle. ### Data format Each stored change is represented as JSON and has the following fields: -(Fields marked by `-` are considered private and not stored in the public table.) +(Fields marked by `?` are optional.) +* `studentId` - the id of the student * `change` - the type of the change -* `-reason` - the type of the change -* `context` - arbitrary data used to handle the type of change +* `?reason` - the type of the change +* `?context` - arbitrary data used to handle the type of change ### Changes diff --git a/src/cockpit.js b/src/cockpit.js index 9dea658..67619d8 100644 --- a/src/cockpit.js +++ b/src/cockpit.js @@ -1,28 +1,24 @@ // import { // fetchSubjectsMock as fetchSubjects, // fetchProgressMock as fetchProgress, -// } from "../../js/mock.js"; +// } from "../../js/mock.ts"; import { OBSERVATION_TYPES, LEVEL_NAMES, GraduationChange, fetchGraduation, - fetchPublicGraduation, submitGraduationChange -} from "./graduation-ENV.js"; +} from "./graduation-ENV.ts"; -import { fetchStudent, fetchStudents, Student } from "./student-ENV.js"; +import { fetchStudent, fetchStudents } from "./student-ENV.ts"; 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; @@ -81,8 +77,7 @@ function pointClickHandler(color) { change = GraduationChange.newRedPointChange(textarea.value, coach); break; default: - alert("Invalid color"); - break; + throw new Error("Invalid color"); } submitGraduationChange(student, change); textareaDiv.style.display = "none"; @@ -102,11 +97,7 @@ function pointClickHandler(color) { } async function fetchAndUpdateStudentInfo() { - if (isTeacher) - student.graduation = await fetchGraduation(student); - else - student.graduation = await fetchPublicGraduation(student); - + student.graduation = await fetchGraduation(student); updateStudentInfo(); } @@ -135,14 +126,12 @@ function updateStudentInfo() { 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")); - } + 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); @@ -174,7 +163,7 @@ function renderObservationStatus(graduation, node) { node.innerHTML = ""; let status; - if (isTeacher || graduation.isObserved() || graduation.observationRequested) { + if (graduation.isObserved() || graduation.observationRequested) { status = document.createElement("span"); status.classList.add("student-observation-status"); if (graduation.isObserved()) { @@ -233,7 +222,6 @@ function renderObservationStatus(graduation, node) { // } async function updateStudentGraduation() { - if (!isTeacher) return; console.assert(student.graduation, "Student has no graduation"); const graduation = student.graduation; @@ -258,7 +246,7 @@ async function updateStudentGraduation() { changeTr.style.background = "orange"; break; default: - alert(`unexpected change: ${change.change}`); + console.error(`unexpected change: ${change.change}`); } continue; } @@ -339,27 +327,16 @@ async function updateStudentGraduation() { } 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()); + initStudentSelection(); + const params = new URLSearchParams(location.search); + const userId = params.get("userToLoad"); + if (userId) { + student = await fetchStudent(userId); await loadStudent(student); } } window.onload = function() { - isTeacher = getIsTeacher(); DEFAULT_STUDENT_ICON = document.getElementById("student-info-icon").src; name = getName(); initCockpit(); diff --git a/src/database-mock.ts b/src/database-mock.ts new file mode 100644 index 0000000..24cb08f --- /dev/null +++ b/src/database-mock.ts @@ -0,0 +1,30 @@ +interface DatabaseParams { + [key: string]: string; +} + +interface DatabaseEntry { + [key: string]: string; +} + +class DB { + async fetchEntries(params?: DatabaseParams) { + console.log(`Mock DB fetch entries: ${params}`); + return []; + } + + async saveEntry(json: string) { + console.log(`Mock DB save entry ${json}`); + return; + } + +} + +async function getGraduationDb() { + return new DB(); +} + +export { + DB, + DatabaseEntry, DatabaseParams, + getGraduationDb, +}; diff --git a/src/database.js b/src/database-prod.ts index e929896..6a55f25 100644 --- a/src/database.js +++ b/src/database-prod.ts @@ -21,67 +21,62 @@ const DB_TEMPLATE_LIST_FOOTER = ` </table> `; -let studentDbs = populateStudentDbs(); +let graduationDb = findGraduationDb(); const ID_QUERY_PARAM_REGEX = /[?&]id=(\d+)/ -async function populateStudentDbs() { +async function findGraduationDb() { + let graduationDbId = localStorage.getItem("gruaduationDbId"); + if (graduationDbId !== null) { + return new DB(Number(graduationDbId)); + } + 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, {}); + for (const a of courseDoc.querySelectorAll("div.activityname a.aalink")) { + if (a.textContent.trim() === "Graduation Datenbank") { + const anchor = (a as HTMLAnchorElement); + const match = anchor.href.match(ID_QUERY_PARAM_REGEX); + if (match === null) continue; + const dbId = match[1]; + localStorage.setItem("graduationDbId", dbId); + return new DB(Number(dbId)) } - - dbs.get(studentId)[dbType.toLowerCase()] = new DB(dbId); } + console.assert(false, "Graduation DB not found"); +} - return dbs; +interface DatabaseParams { + [key: string]: string; } -async function getSubmitDb() { - const dbs = await studentDbs; - return dbs.get("submit"); +interface DatabaseEntry { + [key: string]: string; } class DB { - constructor(id) { + id: number; + + constructor(id: number) { this.id = id; } - async fetchEntries(params) { + async fetchEntries(params?: DatabaseParams) { return loadFromDatabaseActivity(this.id, params) } - async saveEntry(json) { + async saveEntry(json: string) { return addEntryToDatabaseActivity(this.id, json); } - static async getStudentDbs(student) { - const dbs = await studentDbs; - return dbs.get(student.id); - } } -async function loadFromDatabaseActivity(id, params) { +async function loadFromDatabaseActivity(id: string | number, params?: DatabaseParams) { 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) { + if (typeof params !== "undefined") { dbUrl += "&filter=1&advanced=1"; for (const param in params) { dbUrl += `&${param}=${encodeURIComponent(params[param])}` @@ -90,38 +85,37 @@ async function loadFromDatabaseActivity(id, params) { const dbRes = await fetch(encodeURI(dbUrl)); const dbDoc = new DOMParser().parseFromString(await dbRes.text(), 'text/html'); - let data = []; + let data: any[] = []; dbDoc.querySelectorAll('.db-row').forEach(row => { - let entry = {}; - row.querySelectorAll('.db-cell').forEach(field => { - const key = field.dataset.key; + let entry: DatabaseEntry = {}; + row.querySelectorAll<HTMLElement>('.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; + value = field.querySelector("span")?.title; } else { value = field.innerText.trim(); } - entry[key] = value; + 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 getGraduationDb() { + return (await graduationDb)!; } -async function addEntryToDatabaseActivity(id, json) { +async function addEntryToDatabaseActivity(id: number, json: string) { + // TODO(muhq): do not fetch the form 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 form = editDoc.querySelector<HTMLFormElement>('div[role="main"] > form'); - const formData = new FormData(form); + const formData = new FormData(form!); for (const key of formData.keys()) { if (key.startsWith("field_")) { @@ -139,22 +133,8 @@ async function addEntryToDatabaseActivity(id, json) { }); } -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, + DatabaseEntry, DatabaseParams, + getGraduationDb, }; diff --git a/src/graduation-common.js b/src/graduation-common.js deleted file mode 100644 index 0c28465..0000000 --- a/src/graduation-common.js +++ /dev/null @@ -1,396 +0,0 @@ -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-common.ts b/src/graduation-common.ts new file mode 100644 index 0000000..61600d3 --- /dev/null +++ b/src/graduation-common.ts @@ -0,0 +1,347 @@ +import { getGraduationDb, DatabaseEntry } from "./database-ENV.ts" +import type { Student } from "./student-common.ts" + +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 { + date: Date; + studentId: number; + change: string; + reason: string; + coach: string; + context: any; + + constructor(date: Date, studentId: number, change: string, reason: string, coach: string, context: any) { + this.date = date; + this.studentId = studentId; + this.change = change; + this.reason = reason; + this.coach = coach; + this.context = context; + } + + dbFields () { + return { + "studentId": this.studentId, + "change": this.change, + "reason": this.reason, + "context": this.context, + }; + } + + static fromDBEntry(e: DatabaseEntry) { + const {date, coach, json} = e; + const change = JSON.parse(json); + return new GraduationChange(new Date(date), change.change, change.student, change.reason, coach, change.context); + } + + static newObservationRequest() { + return new GraduationChange(new Date(), -1, "ObservationRequest", "", "", ""); + } + + static newObservationStart() { + return new GraduationChange(new Date(), -1, "ObservationStart", "", "", ""); + } + + static newObservationEnd(reason: string, success: boolean) { + return new GraduationChange(new Date(), -1, "ObservationEnd", reason, "", JSON.stringify(success)); + } + + static newObservationChange(observation: Observation) { + return new GraduationChange(observation.date, -1, "Observation", "", "", JSON.stringify(observation.status)); + } + + static newGreenPointChange(reason: string, coach: string) { + return new GraduationChange(new Date(), -1, "Grün", reason, coach, ""); + } + + static newOrangePointChange(reason: string, coach: string) { + return new GraduationChange(new Date(), -1, "Orange", reason, coach, ""); + } + + static newRedPointChange(reason: string, coach: string) { + return new GraduationChange(new Date(), -1, "Rot", reason, coach, ""); + } +} + +class Graduation { + student: Student; + green: number; + orange: number; + red: number; + level: number; + observationRequested: Date | null; + observation: ObservationPeriod | null; + pastObservations: Array<ObservationPeriod>; + changes: Array<GraduationChange>; + + constructor(student: Student, green: number, orange: number, red: number, level: number, observationRequested: Date | null, observation: ObservationPeriod | null, pastObservations: Array<ObservationPeriod>, changes: Array<GraduationChange>) { + this.student = student; + this.green = green; + this.orange = orange; + this.red = red; + this.level = level; + this.observationRequested = observationRequested; + this.observation = observation; + this.pastObservations = pastObservations; + this.changes = changes; + + student.graduation = this; + } + + static from(student: Student, changes: Array<GraduationChange>) { + let green = 0; + let orange = 0; + let red = 0; + let level = 1; + let openObservationRequest = null; + let observationPeriod = null; + let pastObservations = []; + + // The parsing logic assumes that changes are ordered chronologically + changes.sort((a, b) => a.date.valueOf() - b.date.valueOf()); + + 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 === null, + "No request for observation start"); + + observationPeriod = new ObservationPeriod(change.date); + openObservationRequest = null; + break; + + case "ObservationEnd": + if (observationPeriod === null) { + console.error("No active observation period"); + continue; + } + observationPeriod.finishPeriod(change.context.success); + if (change.context.success) { + level++; + } + pastObservations.push(observationPeriod); + observationPeriod = null; + break; + + case "Observation": + if (observationPeriod === null) { + console.error("No active observation period"); + continue; + } + const observation = Observation.fromChange(change); + observationPeriod.addObservation(observation); + break; + + case "LevelChange": + level = change.context.level; + break; + + default: + throw new Error(`Unexpected change: ${change}`); + } + } + return new Graduation(student, green, orange, red, level, + openObservationRequest, observationPeriod, pastObservations, + changes); + } + + addChange(change: GraduationChange) { + change.studentId = this.student.id; + // 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; + const db = await getGraduationDb(); + await db.saveEntry(JSON.stringify(request.dbFields())); + } + + /** + * Start an observation + */ + async startObservation() { + if (this.observationRequested === null) { + console.error("No observation requested"); + return; + } + this.observationRequested = null; + this.observation = new ObservationPeriod(new Date()); + return submitGraduationChange(this, GraduationChange.newObservationStart()); + } + + /** + * End an observation + */ + async endObservation(reason: string, success: boolean) { + if (this.observation === null) { + console.error("No active observation"); + return; + } + this.observation.end = new Date(); + this.pastObservations.push(this.observation); + this.observation = null; + return submitGraduationChange(this, 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. + */ + getLevelName() { + return LEVEL_NAMES[this.level - 1]; + } +} + +class ObservationPeriod { + start: Date; + end: Date | null; + observations: Array<Observation>; + success = false; + + constructor(start: Date) { + this.start = start; + this.end = null; + this.observations = []; + } + + finishPeriod(success: boolean) { + 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(null).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: 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 { + date: Date; + status: Array<string>; + + constructor(date: Date, status: Array<string>) { + console.assert(status.length == OBSERVATION_TYPES.length, "invalid observation status"); + this.date = date; + this.status = status; + } + + static fromChange(change: GraduationChange) { + return new Observation(change.date, JSON.parse(change.context)) + } +} + +async function submitGraduationChange(graduation: Graduation, change: GraduationChange) { + graduation.addChange(change); + const db = await getGraduationDb(); + return db.saveEntry(JSON.stringify(change.dbFields())); +} + +async function submitObservation(graduation: Graduation, observation: Observation) { + if(graduation.observation === null) throw new Error("no active observation"); + graduation.observation.addObservation(observation); + await submitGraduationChange(graduation, GraduationChange.newObservationChange(observation)); +} + +export { + LEVEL_NAMES, + LEVEL_IMAGES, + GraduationChange, + Graduation, + submitGraduationChange, + submitObservation, + + OBSERVATION_TYPES, + Observation, + ObservationPeriod, +}; diff --git a/src/graduation-mock.js b/src/graduation-mock.ts index 1c84433..84a7143 100644 --- a/src/graduation-mock.js +++ b/src/graduation-mock.ts @@ -6,21 +6,19 @@ import { OBSERVATION_TYPES, Observation, ObservationPeriod, -} from "./graduation-common.js"; +} from "./graduation-common.ts"; import { fetchGraduationMock as fetchGraduation, - fetchPublicGraduationMock as fetchPublicGraduation, submitGraduationChangeMock as submitGraduationChange, submitObservationMock as submitObservation -} from "./mock.js"; +} from "./mock.ts"; export { LEVEL_NAMES, GraduationChange, Graduation, fetchGraduation, - fetchPublicGraduation, submitGraduationChange, submitObservation, diff --git a/src/graduation-prod.js b/src/graduation-prod.js deleted file mode 100644 index 0e86956..0000000 --- a/src/graduation-prod.js +++ /dev/null @@ -1,15 +0,0 @@ -export { - LEVEL_NAMES, - GraduationChange, - Graduation, - fetchGraduation, - fetchPublicGraduation, - fetchGraduationChanges, - fetchPublicGraduationChanges, - submitGraduationChange, - submitObservation, - - OBSERVATION_TYPES, - Observation, - ObservationPeriod, -} from "./graduation-common.js"; diff --git a/src/graduation-prod.ts b/src/graduation-prod.ts new file mode 100644 index 0000000..975fd0f --- /dev/null +++ b/src/graduation-prod.ts @@ -0,0 +1,43 @@ +import { getGraduationDb } from "./database-ENV.ts" +import { Graduation, GraduationChange } from "./graduation-common.ts"; +import type { Student } from "./student-common.ts"; + +const _graduationChangesPromise = async function() { + const db = await getGraduationDb(); + let changes = new Map(); + let entries = await db.fetchEntries(); + for (const e of entries) { + const gradChange = GraduationChange.fromDBEntry(e); + const studentChanges = changes.get(gradChange.studentId); + if (studentChanges === null) { + changes.set(gradChange.studentId, [gradChange]); + } else { + studentChanges.push(gradChange); + } + } + return changes; +}(); + +const graduations: Map<number, Graduation> = new Map(); + +export async function fetchGraduation(student: Student): Promise<Graduation> { + if (!graduations.has(student.id)) { + const studentChanges = (await _graduationChangesPromise).get(student.id) ?? []; + const graduation = Graduation.from(student, studentChanges); + graduations.set(student.id, graduation); + return graduation; + } + return graduations.get(student.id)!; +} + +export { + LEVEL_NAMES, + GraduationChange, + Graduation, + submitGraduationChange, + submitObservation, + + OBSERVATION_TYPES, + Observation, + ObservationPeriod, +} from "./graduation-common.ts"; diff --git a/src/group-fetch.ts b/src/group-fetch.ts new file mode 100644 index 0000000..de0991d --- /dev/null +++ b/src/group-fetch.ts @@ -0,0 +1,18 @@ +import { fetchStudents } from "./student-ENV.ts"; +import { Group, buildGroups, _groups } from "./group.ts"; + +/** + * 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/group.js b/src/group.js deleted file mode 100644 index d52707b..0000000 --- a/src/group.js +++ /dev/null @@ -1,57 +0,0 @@ -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; - } -} - -const 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/group.ts b/src/group.ts new file mode 100644 index 0000000..e840ffd --- /dev/null +++ b/src/group.ts @@ -0,0 +1,45 @@ +import type { Student } from "./student-common.ts"; + +/** + * Class to represent a Group of Students. + */ +class Group { + name: string; + students: Array<Student> = []; + + constructor(name: string, students?: Array<Student>) { + this.name = name; + if (typeof students !== "undefined") { + this.students = students; + } + } + + setStudents(students: Array<Student>) { + this.students = students; + } + + addStudent(student: Student) { + this.students.push(student); + } + + async getStudents() { + return this.students; + } +} + +const groups = new Map(); + +function buildGroups(students: Array<Student>) { + 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); + } +} + +export { Group, buildGroups, groups as _groups }; diff --git a/src/mass-graduation.js b/src/mass-graduation.js index 0a69cd1..b9240f0 100644 --- a/src/mass-graduation.js +++ b/src/mass-graduation.js @@ -4,9 +4,9 @@ import { submitGraduationChange, Observation, submitObservation -} from "./graduation-ENV.js"; +} from "./graduation-ENV.ts"; -import { fetchGroups } from "./group.js"; +import { fetchGroups } from "./group-fetch.ts"; import { initSelection } from "./selection.js"; import { getName, DATETIME_FMT, datetimeStringNow } from "./utils-ENV.js"; diff --git a/src/mock.js b/src/mock.js deleted file mode 100644 index 33d1a1a..0000000 --- a/src/mock.js +++ /dev/null @@ -1,104 +0,0 @@ -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/mock.ts b/src/mock.ts new file mode 100644 index 0000000..220b1e3 --- /dev/null +++ b/src/mock.ts @@ -0,0 +1,81 @@ +import { Graduation, GraduationChange, Observation } from "./graduation-common.ts"; +import { Student } from "./student-common.ts"; +import { buildGroups } from "./group.ts"; + +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); + +async function fetchStudentsMock(_id?: number, _name?: string, _group?: string) { + return students; +} + +async function fetchSubjectsMock() { + return ['Mathe', 'Deutsch', 'Englisch']; +} + +function fetchProgressMock(subject: string) { + 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 graduations = new Map(); +for (const s of students) { + switch (s.name) { + case "foo bar": + graduations.set(s, Graduation.from(s, [ + GraduationChange.newGreenPointChange("Gute Arbeit", "Florian Fischer"), + GraduationChange.newOrangePointChange("", "Florian Fischer"), + ])); + break; + case "maxi mustermensch": + graduations.set(s, Graduation.from(s, [ + GraduationChange.newGreenPointChange("", "Florian Fischer"), + GraduationChange.newGreenPointChange("", "Florian Fischer"), + new GraduationChange(new Date(), s.id, "ObservationStart", "", "Florian Fischer", ""), + ])); + break; + case "b b b": + graduations.set(s, Graduation.from(s, [ + GraduationChange.newRedPointChange("", "Florian Fischer"), + ])); + } +} + +async function fetchGraduationMock(s: Student) { + return graduations.get(s); +} + +function submitGraduationChangeMock(s: Student, c: GraduationChange) { + if (s.graduation === null) throw new Error("no graduation"); + s.graduation.addChange(c); +} + +function submitObservationMock(s: Student, o: Observation) { + if (s.graduation === null) throw new Error("no graduation"); + if (s.graduation.observation === null) throw new Error("no observation period"); + s.graduation.observation.addObservation(o); +} + +export { + fetchStudentsMock, + fetchSubjectsMock, + fetchProgressMock, + fetchGraduationMock, + submitGraduationChangeMock, + submitObservationMock, +}; diff --git a/src/student-common.js b/src/student-common.js deleted file mode 100644 index 2dfa35d..0000000 --- a/src/student-common.js +++ /dev/null @@ -1,115 +0,0 @@ -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-common.ts b/src/student-common.ts new file mode 100644 index 0000000..a76a816 --- /dev/null +++ b/src/student-common.ts @@ -0,0 +1,42 @@ +import type { Graduation } from "./graduation-common.ts"; +/** + * Class to represent Students. + */ +export class Student { + id: number; + name: string; + group: string; + graduation: Graduation | null = null; + + constructor(id: number, name: string, group: string) { + this.id = id; + this.name = name; + this.group = group; + } + + getGroup() { + return this.group; + } + + /** + static async fetchById(id: number) { + 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); + } + */ +} diff --git a/src/student-mock.js b/src/student-mock.js deleted file mode 100644 index 7c79e12..0000000 --- a/src/student-mock.js +++ /dev/null @@ -1,9 +0,0 @@ -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-mock.ts b/src/student-mock.ts new file mode 100644 index 0000000..93b45b0 --- /dev/null +++ b/src/student-mock.ts @@ -0,0 +1,25 @@ +import type { Graduation } from "./graduation-common.ts"; +import { Student } from "./student-common.ts"; +import { fetchStudentsMock as fetchStudents, fetchGraduationMock } from "./mock.ts"; + +declare module "./student-common.ts" { + interface Student { + getGraduation(): Promise<Graduation>; + getIconUrl(): Promise<string>; + } +} +Student.prototype.getGraduation = function () { + return fetchGraduationMock(this); +}; + +Student.prototype.getIconUrl = async function () { + return ""; +}; + + +async function fetchStudent(_idFilter?: number, _nameFilter?: string, _groupFilter?: string) { + const students = await fetchStudents(); + return students[0]; +} + +export { Student, fetchStudents, fetchStudent }; diff --git a/src/student-prod.js b/src/student-prod.js deleted file mode 100644 index 9c8d7cb..0000000 --- a/src/student-prod.js +++ /dev/null @@ -1,2 +0,0 @@ -import { Student, fetchStudent, fetchStudents } from "./student-common.js"; -export { Student, fetchStudents, fetchStudent }; diff --git a/src/student-prod.ts b/src/student-prod.ts new file mode 100644 index 0000000..24ac869 --- /dev/null +++ b/src/student-prod.ts @@ -0,0 +1,90 @@ +import type { Graduation } from "./graduation-common.ts"; +import { Student } from "./student-common.ts"; +import { getUserIconUrl, getCourseParticipantsUrl } from "./utils-prod.js" +import { fetchGraduation } from "./graduation-prod.ts"; + +declare module "./student-common.ts" { + interface Student { + getGraduation(): Promise<Graduation>; + getIconUrl(): Promise<string>; + } +} +Student.prototype.getGraduation = function () { + return fetchGraduation(this); +}; + +Student.prototype.getIconUrl = function () { + return getUserIconUrl(this.id); +}; + +// Cache the fetched students. +let students: Array<Student> | null = null; + +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")!; + const fetchedStudents: Array<Student> = []; + 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<HTMLElement>(".c1")!; + let name; + const c = nameCell.firstChild!.firstChild! as HTMLElement; + if (c.tagName == "SPAN") + name = c.title; + else + name = nameCell.innerText; + + const profileUrl = nameCell.querySelector<HTMLAnchorElement>("a.aabtn")!.href; + const match = idRegex.exec(profileUrl); + if (match === null) { + console.error("Profile Regex did not match"); + return; + } + const id = match[1]; + + const groupCell = row.querySelector(".c5")!; + const group = groupCell.querySelector<HTMLElement>("a.quickeditlink")!.textContent; + + fetchedStudents.push(new Student(Number(id), name, group)); + }); + return fetchedStudents; +} + +async function fetchStudents(idFilter?: number, nameFilter?: string, groupFilter?: string) { + if (students === null) { + const studentsPromise = _fetchStudents(); + const storedStudents = localStorage.getItem("students"); + if (storedStudents) { + studentsPromise.then((fetched) => localStorage.setItem("students", JSON.stringify(fetched))); + students = []; + 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) => + (typeof idFilter === "undefined" || s.id === idFilter) && + (typeof nameFilter === "undefined" || s.name.includes(nameFilter)) && + (typeof groupFilter === "undefined" || s.group.includes(groupFilter))); +} + +async function fetchStudent(idFilter: number, nameFilter: string, groupFilter: string) { + const students = await fetchStudents(idFilter, nameFilter, groupFilter); + return students[0]; +} + +export { Student, fetchStudents, fetchStudent }; diff --git a/src/untyped.d.ts b/src/untyped.d.ts new file mode 100644 index 0000000..bae3ce7 --- /dev/null +++ b/src/untyped.d.ts @@ -0,0 +1,4 @@ +declare module "*.png" { + let data: string; + export default data; +} diff --git a/src/utils-common.ts b/src/utils-common.ts index 89a8ddd..f3ad944 100644 --- a/src/utils-common.ts +++ b/src/utils-common.ts @@ -13,7 +13,7 @@ export function datetimeStringNow() { } // TODO(muhq): Cache the retrieved URL -export async function getUserIconUrl(userId: number) { +export async function getUserIconUrl(userId: number): Promise<string> { 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"); @@ -21,5 +21,5 @@ export async function getUserIconUrl(userId: number) { return ""; } - return (userpicture as HTMLImageElement).src; + return (userpicture as HTMLImageElement).src ?? ""; } diff --git a/src/utils-mock.js b/src/utils-mock.js index e64b59d..ab87ddc 100644 --- a/src/utils-mock.js +++ b/src/utils-mock.js @@ -1,5 +1,11 @@ -import { fetchStudent } from "./student-ENV.js"; -export { DATE_FMT, DATETIME_FMT, dateStringNow, datetimeStringNow } from "../dist/src/utils-common.js"; +import { fetchStudentsMock } from "./mock.ts"; +export { + DATE_FMT, + DATETIME_FMT, + dateStringNow, + datetimeStringNow, + getUserIconUrl +} from "./utils-common.ts"; export function isTeacher() { return !(new URLSearchParams(document.location.search)).has("student"); @@ -11,9 +17,9 @@ export function getFirstAndLastname() { const params = new URLSearchParams(document.location.search); if (params.has("firstname")) - firstname = params.get("firstname") + firstname = params.get("firstname"); if (params.has("lastname")) - lastname = params.get("lastname") + lastname = params.get("lastname"); return [firstname, lastname]; } @@ -23,9 +29,9 @@ export function getName() { const params = new URLSearchParams(document.location.search); if (params.has("firstname")) - name = params.get("firstname") + name = params.get("firstname"); if (params.has("lastname")) - name += params.get("lastname") + name += params.get("lastname"); return name; } @@ -33,9 +39,16 @@ export function getName() { export function getUserId() { const name = getName(); - return fetchStudent(null, name).id; + return fetchStudentsMock.filter((s) => s.name === name )[0].id; } export function getCourseId() { return "1337"; } + +export function getCourseParticipantsUrl() { + console.error("Try to get mocked participations URL"); + return ""; +} + + diff --git a/src/utils-prod.js b/src/utils-prod.js index efcd8e3..f7c89e4 100644 --- a/src/utils-prod.js +++ b/src/utils-prod.js @@ -1,4 +1,10 @@ -export { DATE_FMT, DATETIME_FMT, dateStringNow, datetimeStringNow, getUserIconUrl } from "./utils-common.ts"; +export { + DATE_FMT, + DATETIME_FMT, + dateStringNow, + datetimeStringNow, + getUserIconUrl +} from "./utils-common.ts"; export function isTeacher() { return Boolean(document.querySelector('.editmode-switch-form')) @@ -34,3 +40,4 @@ export function getCourseId() { export function getCourseParticipantsUrl() { return `https://lernplattform.bycs.de/user/index.php?id=${getCourseId()}&perpage=5000`; } + |
