853 lines
23 KiB
JavaScript
853 lines
23 KiB
JavaScript
"use strict";
|
|
|
|
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) {
|
|
return match[1]
|
|
}
|
|
throw "Unable to retrieve Application ID";
|
|
}
|
|
|
|
async function getXSRFToken() {
|
|
const cookies = document.cookie.split(";")
|
|
const tokenCookie = cookies.find((cookie) => cookie.trim().startsWith("XSRF-TOKEN="))
|
|
|
|
if (tokenCookie == null) {
|
|
throw new Error("Failed to retrieve XSRF token cookie")
|
|
}
|
|
|
|
return decodeURIComponent(tokenCookie.split("=").slice(1).join("="))
|
|
}
|
|
|
|
async function fetchApplicantDetails(applicant, token) {
|
|
return fetch(`https://personal.uni-graz.at/api/erec/job-applications/${applicant.id}`,
|
|
{
|
|
credentials: "same-origin",
|
|
headers: { "X-XSRF-TOKEN": token }
|
|
})
|
|
.then((response) => {
|
|
if (response.ok) {
|
|
return response.json()
|
|
}
|
|
throw `Failed to get applicant ${applicant.id}`
|
|
})
|
|
}
|
|
|
|
async function downloadApplicantFilesWithProgress(applicantDetails, token, onProgress = () => { }) {
|
|
const files = applicantDetails.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(
|
|
`https://personal.uni-graz.at/api/erec/download-file/${afile.file_id}`,
|
|
{
|
|
credentials: "same-origin",
|
|
headers: { "X-XSRF-TOKEN": token }
|
|
}
|
|
)
|
|
|
|
if (!response.ok) {
|
|
throw `Failed to download file ${afile.file_id} for applicant ${applicantDetails.id}`
|
|
}
|
|
|
|
afile.blob = await response.blob()
|
|
completedDownloads += 1
|
|
downloadedBytes += afile.blob.size
|
|
onProgress({ downloaded: completedDownloads, total: files.length, bytes: downloadedBytes })
|
|
}))
|
|
|
|
return applicantDetails
|
|
}
|
|
|
|
async function getApplicant(applicant, token, onProgress = () => { }) {
|
|
const applicantDetails = await fetchApplicantDetails(applicant, token)
|
|
return downloadApplicantFilesWithProgress(applicantDetails, token, onProgress)
|
|
}
|
|
|
|
async function getApplicants() {
|
|
const aid = getApplicationId()
|
|
const token = await getXSRFToken()
|
|
return fetch(`https://personal.uni-graz.at/api/erec/job-applications/procedure/${aid}`,
|
|
{
|
|
credentials: "same-origin",
|
|
headers: { "X-XSRF-TOKEN": token }
|
|
}
|
|
)
|
|
.then((response) => {
|
|
if (response.ok) {
|
|
return response.json()
|
|
}
|
|
throw "Failed to get list of applications"
|
|
})
|
|
.then((jsonData) => {
|
|
return {
|
|
aid,
|
|
token,
|
|
applicants: jsonData
|
|
}
|
|
// return { aid, token, applicants: jsonData }
|
|
})
|
|
}
|
|
|
|
function sanitizeZipPathSegment(value, fallback = "unnamed") {
|
|
const sanitized = String(value ?? "")
|
|
.replace(/[\/\\:*?"<>|]/g, "_")
|
|
.trim()
|
|
|
|
return sanitized === "" ? fallback : sanitized
|
|
}
|
|
|
|
const CSV_FIELDS = [
|
|
"full_name_sorting_variant",
|
|
"id",
|
|
"date_entered",
|
|
"email",
|
|
"gender",
|
|
"academic_title_before_name",
|
|
"academic_title_after_name",
|
|
"professional_title",
|
|
"first_name",
|
|
"last_name",
|
|
"telephone_number",
|
|
"street",
|
|
"zip_code",
|
|
"city",
|
|
"country",
|
|
"date_of_birth",
|
|
"highest_educational_degree",
|
|
"highest_academic_education",
|
|
"final_year_studies",
|
|
"first_language",
|
|
"relevant_links",
|
|
"orcid_id",
|
|
"google_scholar_profile",
|
|
"nationality_arr",
|
|
"how_did_you_become_aware_arr",
|
|
"gdpr_consent",
|
|
"contact_id",
|
|
"job_procedure",
|
|
"status",
|
|
"suitability",
|
|
"suitability_hightlight_color",
|
|
"related_procedures_count",
|
|
"interview_date_time",
|
|
"avg_rating",
|
|
"comment",
|
|
"hearing_videos_link",
|
|
"dir_name",
|
|
"details_url",
|
|
"files_json"
|
|
]
|
|
|
|
function getApplicantDirectoryName(applicantDetails) {
|
|
return `${sanitizeZipPathSegment(applicantDetails.last_name)}_${sanitizeZipPathSegment(applicantDetails.first_name)}`
|
|
}
|
|
|
|
function getApplicantDetailsUrl(applicantId) {
|
|
return `https://personal.uni-graz.at/#/applicants/record/${applicantId}`
|
|
}
|
|
|
|
function getApplicantArchiveFileName(afile) {
|
|
const sourceName = String(afile.file_name ?? "")
|
|
const extension = sourceName.includes(".")
|
|
? `.${sanitizeZipPathSegment(sourceName.split(".").slice(-1)[0], "file")}`
|
|
: ""
|
|
const baseName = sanitizeZipPathSegment(afile.field_name ?? afile.file_name, "file")
|
|
return `${baseName}${extension}`
|
|
}
|
|
|
|
function getApplicantArchiveFiles(applicantDetails) {
|
|
const dirName = getApplicantDirectoryName(applicantDetails)
|
|
const files = applicantDetails.application_files ?? []
|
|
|
|
return files.map((afile) => {
|
|
const fileName = getApplicantArchiveFileName(afile)
|
|
|
|
return {
|
|
fileName,
|
|
relativePath: `${dirName}/${fileName}`
|
|
}
|
|
})
|
|
}
|
|
|
|
function getApplicantCsvValue(applicantDetails, fieldName) {
|
|
if (fieldName === "dir_name") {
|
|
return getApplicantDirectoryName(applicantDetails)
|
|
}
|
|
|
|
if (fieldName === "details_url") {
|
|
return getApplicantDetailsUrl(applicantDetails.id)
|
|
}
|
|
|
|
if (fieldName === "files_json") {
|
|
return JSON.stringify(getApplicantArchiveFiles(applicantDetails))
|
|
}
|
|
|
|
if (fieldName === "job_procedure") {
|
|
return applicantDetails.job_procedure?.date_entered ?? ""
|
|
}
|
|
|
|
if (fieldName === "nationality_arr" || fieldName === "how_did_you_become_aware_arr") {
|
|
return Array.isArray(applicantDetails[fieldName]) ? applicantDetails[fieldName].join(" ") : ""
|
|
}
|
|
|
|
return applicantDetails[fieldName] ?? ""
|
|
}
|
|
|
|
function escapeCsvValue(value) {
|
|
const stringValue = String(value ?? "")
|
|
if (!/[",\n\r]/.test(stringValue)) {
|
|
return stringValue
|
|
}
|
|
|
|
return `"${stringValue.replace(/"/g, "\"\"")}"`
|
|
}
|
|
|
|
function createApplicantsCsv(applicantDetailsList) {
|
|
const rows = [
|
|
CSV_FIELDS.join(",")
|
|
]
|
|
|
|
applicantDetailsList.forEach((applicantDetails) => {
|
|
const values = CSV_FIELDS.map((fieldName) => escapeCsvValue(getApplicantCsvValue(applicantDetails, fieldName)))
|
|
rows.push(values.join(","))
|
|
})
|
|
|
|
return rows.join("\n")
|
|
}
|
|
|
|
function createApplicantsIndexHtml() {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Applicant archive</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light;
|
|
font-family: Arial, sans-serif;
|
|
line-height: 1.4;
|
|
color: #0f172a;
|
|
background: #f8fafc;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.layout {
|
|
display: flex;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.sidebar {
|
|
width: min(32rem, 42vw);
|
|
min-width: 22rem;
|
|
padding: 1rem;
|
|
overflow-y: auto;
|
|
border-right: 1px solid #cbd5e1;
|
|
background: #ffffff;
|
|
}
|
|
|
|
.content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
}
|
|
|
|
.content-header {
|
|
padding: 1rem 1.25rem;
|
|
border-bottom: 1px solid #cbd5e1;
|
|
background: #ffffff;
|
|
}
|
|
|
|
.content-header h1 {
|
|
margin: 0;
|
|
font-size: 1.25rem;
|
|
}
|
|
|
|
.content-header p {
|
|
margin: 0.35rem 0 0;
|
|
color: #475569;
|
|
}
|
|
|
|
.viewer {
|
|
flex: 1;
|
|
width: 100%;
|
|
border: 0;
|
|
background: #e2e8f0;
|
|
}
|
|
|
|
.placeholder {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
padding: 2rem;
|
|
color: #475569;
|
|
text-align: center;
|
|
}
|
|
|
|
.applicant-card {
|
|
margin-bottom: 1rem;
|
|
padding: 1rem;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 0.75rem;
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.applicant-card h2 {
|
|
margin: 0;
|
|
font-size: 1.05rem;
|
|
}
|
|
|
|
.applicant-title {
|
|
margin: 0.25rem 0 0.75rem;
|
|
color: #475569;
|
|
}
|
|
|
|
.info-list {
|
|
display: grid;
|
|
grid-template-columns: max-content 1fr;
|
|
gap: 0.35rem 0.75rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.info-list dt {
|
|
font-weight: 700;
|
|
}
|
|
|
|
.info-list dd {
|
|
margin: 0;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.details-link {
|
|
display: inline-block;
|
|
margin-top: 0.75rem;
|
|
color: #0f6cbd;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.details-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.file-list-title {
|
|
margin: 1rem 0 0.5rem;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.file-list {
|
|
margin: 0;
|
|
padding-left: 1.1rem;
|
|
}
|
|
|
|
.file-list li + li {
|
|
margin-top: 0.35rem;
|
|
}
|
|
|
|
.file-link {
|
|
color: #0f172a;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.file-link:hover,
|
|
.file-link.active {
|
|
color: #0f6cbd;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.error {
|
|
margin: 0;
|
|
padding: 1rem;
|
|
color: #b91c1c;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="layout">
|
|
<aside class="sidebar">
|
|
<p id="status">Loading applicants.csv...</p>
|
|
<div id="applicants"></div>
|
|
</aside>
|
|
<main class="content">
|
|
<div class="content-header">
|
|
<h1 id="viewer-title">Applicant archive</h1>
|
|
<p id="viewer-subtitle">Select a file on the left to preview it here.</p>
|
|
</div>
|
|
<iframe id="viewer" class="viewer" title="File preview"></iframe>
|
|
</main>
|
|
</div>
|
|
<script>
|
|
function parseCsv(text) {
|
|
const rows = []
|
|
let row = []
|
|
let value = ""
|
|
let index = 0
|
|
let insideQuotes = false
|
|
|
|
while (index < text.length) {
|
|
const char = text[index]
|
|
|
|
if (insideQuotes) {
|
|
if (char === '"') {
|
|
if (text[index + 1] === '"') {
|
|
value += '"'
|
|
index += 1
|
|
} else {
|
|
insideQuotes = false
|
|
}
|
|
} else {
|
|
value += char
|
|
}
|
|
} else if (char === '"') {
|
|
insideQuotes = true
|
|
} else if (char === ',') {
|
|
row.push(value)
|
|
value = ""
|
|
} else if (char === '\\n') {
|
|
row.push(value)
|
|
rows.push(row)
|
|
row = []
|
|
value = ""
|
|
} else if (char === '\\r') {
|
|
} else {
|
|
value += char
|
|
}
|
|
|
|
index += 1
|
|
}
|
|
|
|
row.push(value)
|
|
rows.push(row)
|
|
return rows.filter((currentRow) => currentRow.length > 1 || currentRow[0] !== "")
|
|
}
|
|
|
|
function normalizeText(value) {
|
|
return value || "—"
|
|
}
|
|
|
|
function encodeArchivePath(path) {
|
|
return String(path || "")
|
|
.split("/")
|
|
.map((segment) => encodeURIComponent(segment))
|
|
.join("/")
|
|
}
|
|
|
|
function getDisplayTitle(record) {
|
|
return [record.academic_title_before_name, record.professional_title, record.academic_title_after_name]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
}
|
|
|
|
function parseFiles(record) {
|
|
try {
|
|
const files = JSON.parse(record.files_json || "[]")
|
|
return Array.isArray(files) ? files : []
|
|
} catch (error) {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function readApplicants(records) {
|
|
return records.map((record) => ({
|
|
...record,
|
|
files: parseFiles(record)
|
|
}))
|
|
}
|
|
|
|
function setViewer(file, link) {
|
|
document.querySelectorAll(".file-link.active").forEach((element) => {
|
|
element.classList.remove("active")
|
|
})
|
|
|
|
if (link) {
|
|
link.classList.add("active")
|
|
}
|
|
|
|
const viewer = document.getElementById("viewer")
|
|
const viewerTitle = document.getElementById("viewer-title")
|
|
const viewerSubtitle = document.getElementById("viewer-subtitle")
|
|
const encodedPath = encodeArchivePath(file.relativePath)
|
|
const subtitleLink = document.createElement("a")
|
|
|
|
viewer.src = encodedPath
|
|
viewerTitle.textContent = file.fileName
|
|
subtitleLink.href = encodedPath
|
|
subtitleLink.textContent = file.relativePath
|
|
viewerSubtitle.replaceChildren("Previewing ", subtitleLink)
|
|
}
|
|
|
|
function createInfoRow(label, value) {
|
|
const fragment = document.createDocumentFragment()
|
|
const dt = document.createElement("dt")
|
|
const dd = document.createElement("dd")
|
|
dt.textContent = label
|
|
dd.textContent = normalizeText(value)
|
|
fragment.append(dt, dd)
|
|
return fragment
|
|
}
|
|
|
|
function renderApplicants(applicants) {
|
|
const container = document.getElementById("applicants")
|
|
const status = document.getElementById("status")
|
|
container.replaceChildren()
|
|
status.textContent = applicants.length + " applicants"
|
|
|
|
let firstFileLink = null
|
|
|
|
applicants.forEach((applicant) => {
|
|
const card = document.createElement("section")
|
|
const heading = document.createElement("h2")
|
|
const title = document.createElement("p")
|
|
const infoList = document.createElement("dl")
|
|
const detailsLink = document.createElement("a")
|
|
const fileListTitle = document.createElement("h3")
|
|
const fileList = document.createElement("ul")
|
|
|
|
card.className = "applicant-card"
|
|
heading.textContent = normalizeText(applicant.full_name_sorting_variant)
|
|
title.className = "applicant-title"
|
|
title.textContent = getDisplayTitle(applicant)
|
|
title.hidden = title.textContent === ""
|
|
infoList.className = "info-list"
|
|
detailsLink.className = "details-link"
|
|
detailsLink.href = applicant.details_url
|
|
detailsLink.textContent = "Open applicant details"
|
|
fileListTitle.className = "file-list-title"
|
|
fileListTitle.textContent = "Files"
|
|
fileList.className = "file-list"
|
|
|
|
infoList.append(
|
|
createInfoRow("E-mail", applicant.email),
|
|
createInfoRow("Gender", applicant.gender),
|
|
createInfoRow("Date of birth", applicant.date_of_birth),
|
|
createInfoRow("Nationalities", applicant.nationality_arr)
|
|
)
|
|
|
|
applicant.files.forEach((file) => {
|
|
const item = document.createElement("li")
|
|
const link = document.createElement("a")
|
|
|
|
link.className = "file-link"
|
|
link.href = encodeArchivePath(file.relativePath)
|
|
link.textContent = file.fileName
|
|
link.addEventListener("click", (event) => {
|
|
event.preventDefault()
|
|
setViewer(file, link)
|
|
})
|
|
|
|
if (firstFileLink == null) {
|
|
firstFileLink = { file, link }
|
|
}
|
|
|
|
item.append(link)
|
|
fileList.append(item)
|
|
})
|
|
|
|
if (applicant.files.length === 0) {
|
|
const empty = document.createElement("li")
|
|
empty.textContent = "No files"
|
|
fileList.append(empty)
|
|
}
|
|
|
|
card.append(heading, title, infoList, detailsLink, fileListTitle, fileList)
|
|
container.append(card)
|
|
})
|
|
|
|
if (firstFileLink != null) {
|
|
setViewer(firstFileLink.file, firstFileLink.link)
|
|
}
|
|
}
|
|
|
|
fetch("applicants.csv")
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error("Unable to read applicants.csv")
|
|
}
|
|
return response.text()
|
|
})
|
|
.then((text) => {
|
|
const rows = parseCsv(text)
|
|
const [header, ...records] = rows
|
|
const applicants = readApplicants(records.map((row) => Object.fromEntries(header.map((key, index) => [key, row[index] ?? ""]))))
|
|
renderApplicants(applicants)
|
|
})
|
|
.catch((error) => {
|
|
const status = document.getElementById("status")
|
|
status.className = "error"
|
|
status.textContent = error.message
|
|
})
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
}
|
|
|
|
function downloadBlob(blob, fileName) {
|
|
const url = URL.createObjectURL(blob)
|
|
const link = document.createElement("a")
|
|
|
|
link.href = url
|
|
link.download = fileName
|
|
link.click()
|
|
|
|
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("h4")
|
|
const status = document.createElement("h2")
|
|
const list = document.createElement("ul")
|
|
const summary = document.createElement("div")
|
|
const elapsed = document.createElement("p")
|
|
const remaining = 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 = "EPAS Ripper"
|
|
status.classList.add("ripper-progress-status")
|
|
status.textContent = "Retrieving applicants list (will take some time)..."
|
|
list.classList.add("ripper-progress-list")
|
|
summary.classList.add("ripper-progress-summary")
|
|
elapsed.classList.add("ripper-progress-elapsed")
|
|
remaining.classList.add("ripper-progress-remaining")
|
|
size.classList.add("ripper-progress-size")
|
|
actions.classList.add("ripper-progress-actions")
|
|
|
|
const updateSummary = () => {
|
|
const progressValues = [...progress.values()]
|
|
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)}`
|
|
}
|
|
|
|
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, 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 ({ aid, applicants, token }) => {
|
|
progressDialog.setStatus("Downloading applicant files...")
|
|
applicants.forEach((applicant) => {
|
|
progressDialog.initializeApplicant(applicant)
|
|
})
|
|
|
|
const applicantDetailsResults = await Promise.allSettled(applicants.map(async (applicant) => {
|
|
try {
|
|
const applicantDetails = await getApplicant(applicant, token, ({ downloaded, total, bytes }) => {
|
|
progressDialog.updateApplicant(applicant, downloaded, total, bytes)
|
|
})
|
|
return applicantDetails
|
|
} catch (error) {
|
|
progressDialog.markApplicantFailed(applicant)
|
|
throw error
|
|
}
|
|
}))
|
|
|
|
progressDialog.setStatus("Creating zip archive...")
|
|
const zip = new JSZip()
|
|
const successfulApplicants = []
|
|
|
|
applicantDetailsResults.forEach((result) => {
|
|
if (result.status !== "fulfilled") {
|
|
throw result.reason
|
|
}
|
|
|
|
const applicantDetails = result.value
|
|
const dirName = getApplicantDirectoryName(applicantDetails)
|
|
const applicantDir = zip.folder(dirName)
|
|
const archiveFiles = getApplicantArchiveFiles(applicantDetails)
|
|
|
|
successfulApplicants.push(applicantDetails)
|
|
|
|
archiveFiles.forEach((archiveFile, index) => {
|
|
const afile = applicantDetails.application_files[index]
|
|
applicantDir.file(archiveFile.fileName, afile.blob)
|
|
})
|
|
})
|
|
|
|
zip.file("applicants.csv", createApplicantsCsv(successfulApplicants))
|
|
zip.file("index.html", createApplicantsIndexHtml())
|
|
const zipBlob = await zip.generateAsync({ type: "blob" })
|
|
progressDialog.setStatus("Download ready.")
|
|
downloadBlob(zipBlob, `applications_${aid}.zip`)
|
|
btn.textContent = "done"
|
|
})
|
|
.catch((error) => {
|
|
console.error(error)
|
|
progressDialog.setStatus(`Failed: ${error}`)
|
|
btn.textContent = "failed"
|
|
btn.disabled = false
|
|
})
|
|
.finally(() => {
|
|
btn.classList.remove("loading")
|
|
progressDialog.stopTimer()
|
|
progressDialog.enableClose()
|
|
})
|
|
}
|
|
|
|
// injection of rip button
|
|
function install() {
|
|
const titleTag = document.querySelector("scrm-module-title")
|
|
|
|
if (titleTag != null) {
|
|
if (titleTag.querySelector(".ripper-btn") != null) {
|
|
return
|
|
}
|
|
|
|
const a = document.createElement("button")
|
|
a.textContent = "rip"
|
|
a.classList.add("ripper-btn")
|
|
a.addEventListener("click", rip, {
|
|
once: true
|
|
})
|
|
titleTag.append(" (", a, ")")
|
|
} else {
|
|
console.log("could not install button")
|
|
}
|
|
}
|
|
|
|
// we need the observer to restore the button after each page load
|
|
const contentObserver = new MutationObserver((mutations) => {
|
|
mutations.forEach(mu => {
|
|
for (const node of mu.removedNodes) {
|
|
if (node.nodeName == "APP-FULL-PAGE-SPINNER") {
|
|
install();
|
|
}
|
|
}
|
|
});
|
|
})
|
|
|
|
contentObserver.observe(document.querySelector("app-root"), { subtree: true, childList: true });
|