add counters
This commit is contained in:
parent
68a20e8126
commit
9a9e31bf47
2 changed files with 211 additions and 44 deletions
249
content.js
249
content.js
|
|
@ -1,7 +1,5 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const extensionApi = globalThis.browser ?? globalThis.chrome;
|
|
||||||
|
|
||||||
function getApplicationId() {
|
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})/)
|
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) {
|
if (match != null) {
|
||||||
|
|
@ -11,35 +9,21 @@ function getApplicationId() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getXSRFToken() {
|
async function getXSRFToken() {
|
||||||
// we need to go through the background script to get the XSRF-TOKEN cookie
|
const cookies = document.cookie.split(";")
|
||||||
return new Promise((resolve, reject) => {
|
const tokenCookie = cookies.find((cookie) => cookie.trim().startsWith("XSRF-TOKEN="))
|
||||||
extensionApi.runtime.sendMessage(
|
|
||||||
{ type: "COOKIE", url: "https://personal.uni-graz.at", key: "XSRF-TOKEN" },
|
|
||||||
(cookie) => {
|
|
||||||
const runtimeError = extensionApi.runtime.lastError;
|
|
||||||
|
|
||||||
if (runtimeError) {
|
if (tokenCookie == null) {
|
||||||
reject(new Error(runtimeError.message))
|
throw new Error("Failed to retrieve XSRF token cookie")
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (cookie?.error != null) {
|
return decodeURIComponent(tokenCookie.split("=").slice(1).join("="))
|
||||||
reject(new Error(cookie.error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cookie?.value == null) {
|
|
||||||
reject(new Error("Failed to retrieve XSRF token cookie"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(cookie.value)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getApplicant(applicant, token) {
|
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}`,
|
return fetch(`https://personal.uni-graz.at/api/erec/job-applications/${applicant.id}`,
|
||||||
{
|
{
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
|
|
@ -53,6 +37,10 @@ async function getApplicant(applicant, token) {
|
||||||
})
|
})
|
||||||
.then(async (jsonData) => {
|
.then(async (jsonData) => {
|
||||||
const files = jsonData.application_files ?? []
|
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) => {
|
await Promise.all(files.map(async (afile) => {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
|
|
@ -68,6 +56,9 @@ async function getApplicant(applicant, token) {
|
||||||
}
|
}
|
||||||
|
|
||||||
afile.blob = await response.blob()
|
afile.blob = await response.blob()
|
||||||
|
completedDownloads += 1
|
||||||
|
downloadedBytes += afile.blob.size
|
||||||
|
onProgress({ downloaded: completedDownloads, total: files.length, bytes: downloadedBytes })
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return jsonData
|
return jsonData
|
||||||
|
|
@ -90,8 +81,12 @@ async function getApplicants() {
|
||||||
throw "Failed to get list of applications"
|
throw "Failed to get list of applications"
|
||||||
})
|
})
|
||||||
.then((jsonData) => {
|
.then((jsonData) => {
|
||||||
const sliced = jsonData.slice(0, 2);
|
return {
|
||||||
return Promise.allSettled(sliced.map(async (applicant) => getApplicant(applicant, token)))
|
aid,
|
||||||
|
token,
|
||||||
|
applicants: jsonData
|
||||||
|
}
|
||||||
|
// return { aid, token, applicants: jsonData }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,16 +109,192 @@ function downloadBlob(blob, fileName) {
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
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) {
|
function rip(event) {
|
||||||
const btn = event.target;
|
const btn = event.target;
|
||||||
|
const progressDialog = createProgressDialog()
|
||||||
btn.textContent = "working ..."
|
btn.textContent = "working ..."
|
||||||
btn.classList.add("loading")
|
btn.classList.add("loading")
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
getApplicants()
|
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()
|
const zip = new JSZip()
|
||||||
|
|
||||||
applicants.forEach((result) => {
|
applicantResults.forEach((result) => {
|
||||||
if (result.status !== "fulfilled") {
|
if (result.status !== "fulfilled") {
|
||||||
throw result.reason
|
throw result.reason
|
||||||
}
|
}
|
||||||
|
|
@ -146,16 +317,20 @@ function rip(event) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const zipBlob = await zip.generateAsync({ type: "blob" })
|
const zipBlob = await zip.generateAsync({ type: "blob" })
|
||||||
downloadBlob(zipBlob, `applications_${getApplicationId()}.zip`)
|
progressDialog.setStatus("Download ready.")
|
||||||
|
downloadBlob(zipBlob, `applications_${aid}.zip`)
|
||||||
btn.textContent = "done"
|
btn.textContent = "done"
|
||||||
})
|
})
|
||||||
// .catch((error) => {
|
.catch((error) => {
|
||||||
// console.error(error)
|
console.error(error)
|
||||||
// btn.textContent = "failed"
|
progressDialog.setStatus(`Failed: ${error}`)
|
||||||
// btn.disabled = false
|
btn.textContent = "failed"
|
||||||
// })
|
btn.disabled = false
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
btn.classList.remove("loading")
|
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 });
|
contentObserver.observe(document.querySelector("app-root"), { subtree: true, childList: true });
|
||||||
|
|
||||||
console.log(JSZip.support)
|
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,9 @@
|
||||||
"name": "KF-Application-Downloader",
|
"name": "KF-Application-Downloader",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Downloads all files from an application procedure at the KFU",
|
"description": "Downloads all files from an application procedure at the KFU",
|
||||||
"permissions": [
|
|
||||||
"cookies"
|
|
||||||
],
|
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://personal.uni-graz.at/*"
|
"https://personal.uni-graz.at/*"
|
||||||
],
|
],
|
||||||
"background": {
|
|
||||||
"service_worker": "background.js"
|
|
||||||
},
|
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"matches": [
|
"matches": [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue