From 9a9e31bf4752201fffd18f6731886ba5b22be9bb Mon Sep 17 00:00:00 2001 From: Gaspard Jankowiak Date: Thu, 4 Jun 2026 22:49:14 +0200 Subject: [PATCH] add counters --- content.js | 249 ++++++++++++++++++++++++++++++++++++++++++-------- manifest.json | 6 -- 2 files changed, 211 insertions(+), 44 deletions(-) diff --git a/content.js b/content.js index aab33ef..9682ffb 100644 --- a/content.js +++ b/content.js @@ -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) diff --git a/manifest.json b/manifest.json index dd7c5eb..1458c9d 100644 --- a/manifest.json +++ b/manifest.json @@ -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": [