add counters

This commit is contained in:
Gaspard Jankowiak 2026-06-04 22:49:14 +02:00
commit 9a9e31bf47
2 changed files with 211 additions and 44 deletions

View file

@ -1,7 +1,5 @@
"use strict";
const extensionApi = globalThis.browser ?? globalThis.chrome;
function getApplicationId() {
const match = window.location.hash.match(/^#\/job-procedures\/record\/([0-9a-z]{8}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{12})/)
if (match != null) {
@ -11,35 +9,21 @@ function getApplicationId() {
}
async function getXSRFToken() {
// we need to go through the background script to get the XSRF-TOKEN cookie
return new Promise((resolve, reject) => {
extensionApi.runtime.sendMessage(
{ type: "COOKIE", url: "https://personal.uni-graz.at", key: "XSRF-TOKEN" },
(cookie) => {
const runtimeError = extensionApi.runtime.lastError;
const cookies = document.cookie.split(";")
const tokenCookie = cookies.find((cookie) => cookie.trim().startsWith("XSRF-TOKEN="))
if (runtimeError) {
reject(new Error(runtimeError.message))
return
if (tokenCookie == null) {
throw new Error("Failed to retrieve XSRF token cookie")
}
if (cookie?.error != null) {
reject(new Error(cookie.error))
return
}
if (cookie?.value == null) {
reject(new Error("Failed to retrieve XSRF token cookie"))
return
}
resolve(cookie.value)
}
)
})
return decodeURIComponent(tokenCookie.split("=").slice(1).join("="))
}
async function getApplicant(applicant, token) {
return getApplicantWithProgress(applicant, token)
}
async function getApplicantWithProgress(applicant, token, onProgress = () => { }) {
return fetch(`https://personal.uni-graz.at/api/erec/job-applications/${applicant.id}`,
{
credentials: "same-origin",
@ -53,6 +37,10 @@ async function getApplicant(applicant, token) {
})
.then(async (jsonData) => {
const files = jsonData.application_files ?? []
let completedDownloads = 0
let downloadedBytes = 0
onProgress({ downloaded: completedDownloads, total: files.length, bytes: downloadedBytes })
await Promise.all(files.map(async (afile) => {
const response = await fetch(
@ -68,6 +56,9 @@ async function getApplicant(applicant, token) {
}
afile.blob = await response.blob()
completedDownloads += 1
downloadedBytes += afile.blob.size
onProgress({ downloaded: completedDownloads, total: files.length, bytes: downloadedBytes })
}))
return jsonData
@ -90,8 +81,12 @@ async function getApplicants() {
throw "Failed to get list of applications"
})
.then((jsonData) => {
const sliced = jsonData.slice(0, 2);
return Promise.allSettled(sliced.map(async (applicant) => getApplicant(applicant, token)))
return {
aid,
token,
applicants: jsonData
}
// return { aid, token, applicants: jsonData }
})
}
@ -114,16 +109,192 @@ function downloadBlob(blob, fileName) {
setTimeout(() => URL.revokeObjectURL(url), 0)
}
function formatApplicantName(applicant) {
return applicant.full_name_sorting_variant
}
function formatDuration(totalSeconds) {
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
}
return `${minutes}:${String(seconds).padStart(2, "0")}`
}
function formatElapsedTime(startTime) {
const elapsedMs = Date.now() - startTime
const totalSeconds = Math.floor(elapsedMs / 1000)
return formatDuration(totalSeconds)
}
function formatMegabytes(totalBytes) {
return `${(totalBytes / (1024 * 1024)).toFixed(1)} MB`
}
function createProgressDialog() {
const dialog = document.createElement("dialog")
const title = document.createElement("h2")
const status = document.createElement("p")
const list = document.createElement("ul")
const summary = document.createElement("div")
const elapsed = document.createElement("p")
const remaining = document.createElement("p")
const eta = document.createElement("p")
const size = document.createElement("p")
const actions = document.createElement("div")
const closeButton = document.createElement("button")
const entries = new Map()
const progress = new Map()
const startTime = Date.now()
let timerId = null
dialog.classList.add("ripper-progress-dialog")
title.textContent = "Download progress"
status.classList.add("ripper-progress-status")
status.textContent = "Retrieving applicants list..."
list.classList.add("ripper-progress-list")
summary.classList.add("ripper-progress-summary")
elapsed.classList.add("ripper-progress-elapsed")
remaining.classList.add("ripper-progress-remaining")
eta.classList.add("ripper-progress-eta")
size.classList.add("ripper-progress-size")
actions.classList.add("ripper-progress-actions")
const updateSummary = () => {
const progressValues = [...progress.values()]
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000)
const hasUnknownTotals = progressValues.some((entry) => entry.total == null)
const totalDownloaded = progressValues.reduce((sum, entry) => sum + (entry.downloaded ?? 0), 0)
const totalFiles = progressValues.reduce((sum, entry) => sum + (entry.total ?? 0), 0)
const totalDownloadedBytes = progressValues.reduce((sum, entry) => sum + (entry.bytes ?? 0), 0)
const filesRemaining = Math.max(totalFiles - totalDownloaded, 0)
elapsed.textContent = `Time elapsed: ${formatElapsedTime(startTime)}`
remaining.textContent = hasUnknownTotals ? "Files remaining: ?" : `Files remaining: ${filesRemaining}`
size.textContent = `Downloaded size: ${formatMegabytes(totalDownloadedBytes)}`
if (hasUnknownTotals || totalDownloaded === 0) {
eta.textContent = filesRemaining === 0 && !hasUnknownTotals ? "ETA: 0:00" : "ETA: --"
return
}
const secondsPerFile = elapsedSeconds / totalDownloaded
eta.textContent = `ETA: ${formatDuration(Math.ceil(secondsPerFile * filesRemaining))}`
}
closeButton.type = "button"
closeButton.textContent = "Close"
closeButton.disabled = true
closeButton.addEventListener("click", () => {
dialog.close()
dialog.remove()
})
dialog.addEventListener("cancel", (event) => {
if (closeButton.disabled) {
event.preventDefault()
}
})
actions.append(closeButton)
summary.append(elapsed, remaining, eta, size)
dialog.append(title, status, list, summary, actions)
document.body.append(dialog)
dialog.showModal()
updateSummary()
timerId = setInterval(updateSummary, 1000)
return {
setStatus(text) {
status.textContent = text
},
initializeApplicant(applicant) {
this.ensureApplicant(applicant)
progress.set(applicant.id, { downloaded: null, total: null, bytes: 0 })
updateSummary()
},
ensureApplicant(applicant) {
if (entries.has(applicant.id)) {
return entries.get(applicant.id)
}
const item = document.createElement("li")
const name = document.createElement("span")
const counter = document.createElement("span")
item.classList.add("ripper-progress-item")
name.classList.add("ripper-progress-name")
counter.classList.add("ripper-progress-counter")
name.textContent = formatApplicantName(applicant)
counter.textContent = "?/?"
item.append(name, counter)
list.append(item)
const entry = { item, counter }
entries.set(applicant.id, entry)
return entry
},
updateApplicant(applicant, downloaded, total, bytes = 0, failed = false) {
const entry = this.ensureApplicant(applicant)
progress.set(applicant.id, { downloaded, total, bytes })
entry.counter.textContent = `${downloaded}/${total}`
entry.item.classList.toggle("failed", failed)
entry.item.classList.toggle("done", !failed && downloaded === total)
updateSummary()
},
markApplicantFailed(applicant) {
const entry = this.ensureApplicant(applicant)
entry.item.classList.add("failed")
entry.item.classList.remove("done")
},
stopTimer() {
if (timerId != null) {
clearInterval(timerId)
timerId = null
}
updateSummary()
},
enableClose() {
closeButton.disabled = false
}
}
}
function rip(event) {
const btn = event.target;
const progressDialog = createProgressDialog()
btn.textContent = "working ..."
btn.classList.add("loading")
btn.disabled = true;
getApplicants()
.then(async (applicants) => {
.then(async ({ aid, applicants, token }) => {
progressDialog.setStatus("Downloading applicant files...")
applicants.forEach((applicant) => {
progressDialog.initializeApplicant(applicant)
})
const applicantResults = await Promise.allSettled(applicants.map(async (applicant) => {
try {
const applicantData = await getApplicantWithProgress(applicant, token, ({ downloaded, total, bytes }) => {
progressDialog.updateApplicant(applicant, downloaded, total, bytes)
})
return applicantData
} catch (error) {
progressDialog.markApplicantFailed(applicant)
throw error
}
}))
progressDialog.setStatus("Creating zip archive...")
const zip = new JSZip()
applicants.forEach((result) => {
applicantResults.forEach((result) => {
if (result.status !== "fulfilled") {
throw result.reason
}
@ -146,16 +317,20 @@ function rip(event) {
})
const zipBlob = await zip.generateAsync({ type: "blob" })
downloadBlob(zipBlob, `applications_${getApplicationId()}.zip`)
progressDialog.setStatus("Download ready.")
downloadBlob(zipBlob, `applications_${aid}.zip`)
btn.textContent = "done"
})
// .catch((error) => {
// console.error(error)
// btn.textContent = "failed"
// btn.disabled = false
// })
.catch((error) => {
console.error(error)
progressDialog.setStatus(`Failed: ${error}`)
btn.textContent = "failed"
btn.disabled = false
})
.finally(() => {
btn.classList.remove("loading")
progressDialog.stopTimer()
progressDialog.enableClose()
})
}
@ -188,5 +363,3 @@ const contentObserver = new MutationObserver((mutations) => {
})
contentObserver.observe(document.querySelector("app-root"), { subtree: true, childList: true });
console.log(JSZip.support)

View file

@ -3,15 +3,9 @@
"name": "KF-Application-Downloader",
"version": "0.1.0",
"description": "Downloads all files from an application procedure at the KFU",
"permissions": [
"cookies"
],
"host_permissions": [
"https://personal.uni-graz.at/*"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": [