"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" ] 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 getApplicantCsvValue(applicantDetails, fieldName) { if (fieldName === "dir_name") { return getApplicantDirectoryName(applicantDetails) } if (fieldName === "details_url") { return getApplicantDetailsUrl(applicantDetails.id) } 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 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 files = applicantDetails.application_files ?? [] successfulApplicants.push(applicantDetails) files.forEach((afile) => { const ext = afile.file_name.split(".").slice(-1) const filename = `${afile.field_name}.${ext}` applicantDir.file(filename, afile.blob) }) }) zip.file("applicants.csv", createApplicantsCsv(successfulApplicants)) 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 });