aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2026-10-17 10:23:22 +0200
committerFlorian Fischer <florian.fischer@muhq.space>2026-10-17 10:23:22 +0200
commit198bbebd89d212faae3e45a22830bab66dec49e0 (patch)
tree1b92b64f29e0bc50f5ff845b154450c926b82a69
parent38477bab6c1c736a2d1e7dd79b75c6e122502622 (diff)
downloadbycs-sol-198bbebd89d212faae3e45a22830bab66dec49e0.tar.gz
bycs-sol-198bbebd89d212faae3e45a22830bab66dec49e0.zip
admin-tools: add export tools and improve duplicate finderHEADmain
Considered as duplicates are eequal qual changes submitted in the same hour. Bump dependencies.
-rw-r--r--admin-tools.html1
-rw-r--r--package-lock.json184
-rw-r--r--package.json4
-rw-r--r--src/admin-tools.js85
-rw-r--r--src/graduation-common.ts54
5 files changed, 236 insertions, 92 deletions
diff --git a/admin-tools.html b/admin-tools.html
index 0b1fb28..7f3b3bc 100644
--- a/admin-tools.html
+++ b/admin-tools.html
@@ -27,5 +27,6 @@
</div>
<button id="print-ids">StudentIds</button>
<button id="find-duplicates">Finde Duplikate</button>
+ <button id="export-graduation-dbs">Exportiere Graduation Dbs</button>
</body>
</html>
diff --git a/package-lock.json b/package-lock.json
index d485859..0d4ab7e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,10 @@
"name": "ByCS-SOL",
"version": "0.1.0",
"license": "GPL-3.0-or-later",
+ "dependencies": {
+ "file-saver": "^2.0.5",
+ "jszip": "^3.10.2"
+ },
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/preset-env": "^8.0.2",
@@ -1609,29 +1613,6 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
- "node_modules/@eslint/config-array/node_modules/balanced-match": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
- "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/@eslint/config-array/node_modules/brace-expansion": {
- "version": "5.0.9",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
- "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^4.0.2"
- },
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -2553,6 +2534,16 @@
"@babel/core": "^7.4.0 || ^8.0.0"
}
},
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.11.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.11.tgz",
@@ -2566,6 +2557,19 @@
"node": ">=6.0.0"
}
},
+ "node_modules/brace-expansion": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/browserslist": {
"version": "4.28.7",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
@@ -2694,6 +2698,12 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2924,29 +2934,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
- "node_modules/eslint/node_modules/balanced-match": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
- "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/eslint/node_modules/brace-expansion": {
- "version": "5.0.9",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
- "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^4.0.2"
- },
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/eslint/node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -3193,9 +3180,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
- "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"dev": true,
"funding": [
{
@@ -3222,6 +3209,12 @@
"node": ">=16.0.0"
}
},
+ "node_modules/file-saver": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
+ "license": "MIT"
+ },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -3340,6 +3333,12 @@
"node": ">= 4"
}
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
"node_modules/import-local": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@@ -3381,6 +3380,12 @@
"node": ">=0.8.19"
}
},
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
"node_modules/interpret": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
@@ -3443,6 +3448,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3545,6 +3556,18 @@
"node": ">=6"
}
},
+ "node_modules/jszip": {
+ "version": "3.10.2",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
+ "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3579,6 +3602,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -3789,6 +3821,12 @@
"node": ">=6"
}
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -3862,6 +3900,12 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3872,6 +3916,21 @@
"node": ">=6"
}
},
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
"node_modules/rechoir": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
@@ -3998,6 +4057,12 @@
"node": ">=8"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
"node_modules/schema-utils": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
@@ -4031,6 +4096,12 @@
"node": ">=10"
}
},
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
"node_modules/shallow-clone": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
@@ -4088,6 +4159,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -4274,6 +4354,12 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
"node_modules/watchpack": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz",
diff --git a/package.json b/package.json
index 08634e3..9262ccf 100644
--- a/package.json
+++ b/package.json
@@ -25,5 +25,9 @@
"typescript": "^7.0.2",
"webpack": "^5.107.2",
"webpack-cli": "^7.0.2"
+ },
+ "dependencies": {
+ "file-saver": "^2.0.5",
+ "jszip": "^3.10.2"
}
}
diff --git a/src/admin-tools.js b/src/admin-tools.js
index 116b5ee..b3135b1 100644
--- a/src/admin-tools.js
+++ b/src/admin-tools.js
@@ -1,9 +1,13 @@
import { GraduationChange, submitGraduationChange } from "./graduation-ENV.ts";
+import { findDuplicateChanges } from "./graduation-common.ts";
import { fetchStudent, fetchStudents } from "./student-ENV.ts";
import { fetchCourses } from "./moodle-ENV.ts";
import { DB } from "./database-ENV.ts";
import { DATETIME_FMT } from "./utils-common.ts";
+import JSZip from "jszip";
+import FileSaver from "file-saver";
+
let outputNode;
function output(msg) {
if (outputNode.style.display === "none") {
@@ -83,53 +87,19 @@ async function findDuplicates(course) {
const entries = await db.fetchEntries();
const changes = entries.map((e) => GraduationChange.fromDBEntry(e));
- const searched = new Set();
- const duplicates = [];
- for (const change of changes) {
- if (searched.has(change)) continue;
- searched.add(change);
-
- const curDuplicates = [change];
- for (const other of changes) {
- if (other === change) continue;
-
- let same = true;
- for (const key in change) {
- switch (key) {
- case "changeId":
- continue;
- case "date":
- same = same && change.date.getTime() === other.date.getTime();
- break;
- case "context":
- same = same && JSON.stringify(change.context) === JSON.stringify(other.context);
- break;
- default:
- same = same && change[key] === other[key];
- }
- if (!same) break;
- }
-
- if (same) {
- curDuplicates.push(other);
- searched.add(other);
- }
- }
+ const duplicates = findDuplicateChanges(changes);
- if (curDuplicates.length > 1) {
- duplicates.push(curDuplicates);
+ if (duplicates.length !== 0) {
+ let msg = `${course.title} Duplikate:<br>`;
+ for (const d of duplicates) {
+ const ids = d.map((e) => e.changeId);
+ const c = d[0];
+ msg += `- ${DATETIME_FMT.format(c.date)} ${c.coach} ${c.studentId} ${c.change} ${JSON.stringify(c.context)} ids: ${ids}<br>`;
}
+ output(msg + "\n");
}
- if (duplicates.length === 0) return;
-
- let msg = `${course.title} Duplikate:<br>`;
- for (const d of duplicates) {
- const ids = d.map((e) => e.changeId);
- const c = d[0];
- msg += `- ${DATETIME_FMT.format(c.date)} ${c.coach} ${c.studentId} ${c.change} ${JSON.stringify(c.context)} ids: ${ids}<br>`;
- }
- output(msg + "\n");
+ return [course, duplicates];
}
async function findAllDuplicates() {
@@ -141,6 +111,34 @@ async function findAllDuplicates() {
}
}
+async function exportGraduations(course) {
+ const dbActivity = await course.getActivity("Graduation");
+ const db = new DB(dbActivity.id);
+ const exportResponse = await db.exportEntries();
+ return [course, await exportResponse.text()];
+}
+
+async function exportAllGraduations() {
+ output("loading courses ...");
+ const courses = await fetchCourses("SOL Graduierung");
+ output(`exporting graduations of ${courses.length} courses ... `);
+ const promises = [];
+ for (const course of courses) {
+ promises.push(exportGraduations(course));
+ }
+ const results = await Promise.all(promises);
+
+ const zip = new JSZip();
+
+ for (const [course, entries] of results) {
+ zip.file(`${course.title}.csv`, entries);
+ }
+
+ zip.generateAsync({ type: "blob" }).then(function (content) {
+ FileSaver.saveAs(content, "graduation-db-exports.zip");
+ });
+}
+
async function init() {
outputNode = document.getElementById("admin-tools-output");
output("Admin tools ready.");
@@ -153,6 +151,7 @@ async function init() {
output(JSON.stringify(students.map((s) => s.id)));
});
document.getElementById("find-duplicates").addEventListener("click", findAllDuplicates);
+ document.getElementById("export-graduation-dbs").addEventListener("click", exportAllGraduations);
}
if (document.readyState === "loading") {
diff --git a/src/graduation-common.ts b/src/graduation-common.ts
index 2d1f21d..fe244bc 100644
--- a/src/graduation-common.ts
+++ b/src/graduation-common.ts
@@ -248,6 +248,60 @@ async function deleteGraduationChange(graduation: Graduation, change: Graduation
return await db.deleteEntries([change.changeId]);
}
+export function findDuplicateChanges(
+ changes: Array<GraduationChange>,
+): Array<Array<GraduationChange>> {
+ // Sets of potential duplicates
+ const duplicates: Array<Array<GraduationChange>> = [];
+
+ // Sort the changes first by studentId and than by time
+ changes.sort((a, b): number => {
+ // Sort by studentId first
+ if (a.studentId !== b.studentId) return a.studentId - b.studentId;
+ return a.date.getTime() - b.date.getTime();
+ });
+
+ let candidates = [];
+ outer_loop: for (let i = 0; i < changes.length - 1; i++) {
+ const change = changes[i];
+ const other = changes[i + 1];
+
+ for (const key in change) {
+ let similar;
+ switch (key) {
+ case "changeId":
+ continue;
+ case "date":
+ // Are the dates less than an hour apart?
+ similar = Math.abs(change.date.getTime() - other.date.getTime()) < 24 * 60 * 60 * 1000;
+ break;
+ case "context":
+ similar = JSON.stringify(change.context) === JSON.stringify(other.context);
+ break;
+ default:
+ const tkey = key as keyof GraduationChange;
+ similar = change[tkey] === other[tkey];
+ }
+ if (!similar) {
+ // The input is sorted duplicates must be in consecutive order.
+ if (candidates.length > 0) {
+ duplicates.push(candidates);
+ candidates = [];
+ }
+ continue outer_loop;
+ }
+ }
+
+ // Found candidates
+ if (candidates.length === 0) {
+ candidates.push(change, other); // New candidate pair
+ } else {
+ candidates.push(other); // Additional candidate found
+ }
+ }
+ return duplicates;
+}
+
export {
LEVEL_NAMES,
LEVEL_IMAGES,