add index.html to browse the archive

This commit is contained in:
Gaspard Jankowiak 2026-06-05 10:14:19 +02:00
commit 2d43c159c4
2 changed files with 215 additions and 61 deletions

View file

@ -244,6 +244,11 @@ function createApplicantsIndexHtml() {
min-height: 100vh; min-height: 100vh;
} }
button,
input {
font: inherit;
}
.layout { .layout {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
@ -281,6 +286,25 @@ function createApplicantsIndexHtml() {
color: #475569; color: #475569;
} }
.controls {
display: grid;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 1rem;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
background: #f8fafc;
}
.controls p {
margin: 0;
color: #475569;
}
.controls input[type="file"] {
width: 100%;
}
.viewer { .viewer {
flex: 1; flex: 1;
width: 100%; width: 100%;
@ -288,16 +312,6 @@ function createApplicantsIndexHtml() {
background: #e2e8f0; background: #e2e8f0;
} }
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 2rem;
color: #475569;
text-align: center;
}
.applicant-card { .applicant-card {
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 1rem; padding: 1rem;
@ -343,6 +357,15 @@ function createApplicantsIndexHtml() {
text-decoration: underline; text-decoration: underline;
} }
.viewer-download {
color: #0f6cbd;
text-decoration: none;
}
.viewer-download:hover {
text-decoration: underline;
}
.file-list-title { .file-list-title {
margin: 1rem 0 0.5rem; margin: 1rem 0 0.5rem;
font-size: 0.95rem; font-size: 0.95rem;
@ -378,18 +401,33 @@ function createApplicantsIndexHtml() {
<body> <body>
<div class="layout"> <div class="layout">
<aside class="sidebar"> <aside class="sidebar">
<p id="status">Loading applicants.csv...</p> <section class="controls">
<p>Select the downloaded ZIP file to browse applicants and preview files.</p>
<input id="zip-input" type="file" accept=".zip,application/zip">
<p id="status">Waiting for ZIP selection...</p>
</section>
<div id="applicants"></div> <div id="applicants"></div>
</aside> </aside>
<main class="content"> <main class="content">
<div class="content-header"> <div class="content-header">
<h1 id="viewer-title">Applicant archive</h1> <h1 id="viewer-title">Applicant archive</h1>
<p id="viewer-subtitle">Select a file on the left to preview it here.</p> <p id="viewer-subtitle">Select the ZIP file, then choose a file on the left to preview it here.</p>
<a id="viewer-download" class="viewer-download" href="#" hidden>Download current file</a>
</div> </div>
<iframe id="viewer" class="viewer" title="File preview"></iframe> <iframe id="viewer" class="viewer" title="File preview"></iframe>
</main> </main>
</div> </div>
<script src="jszip.min.js"></script>
<script> <script>
let archive = null
let currentObjectUrl = null
function setStatus(message, isError) {
const status = document.getElementById("status")
status.textContent = message
status.className = isError ? "error" : ""
}
function parseCsv(text) { function parseCsv(text) {
const rows = [] const rows = []
let row = [] let row = []
@ -438,11 +476,23 @@ function createApplicantsIndexHtml() {
return value || "—" return value || "—"
} }
function encodeArchivePath(path) { function guessMimeType(fileName) {
return String(path || "") const extension = String(fileName || "").split(".").pop().toLowerCase()
.split("/") const mimeTypes = {
.map((segment) => encodeURIComponent(segment)) csv: "text/csv;charset=utf-8",
.join("/") gif: "image/gif",
htm: "text/html;charset=utf-8",
html: "text/html;charset=utf-8",
jpeg: "image/jpeg",
jpg: "image/jpeg",
pdf: "application/pdf",
png: "image/png",
svg: "image/svg+xml",
txt: "text/plain;charset=utf-8",
webp: "image/webp"
}
return mimeTypes[extension] || "application/octet-stream"
} }
function getDisplayTitle(record) { function getDisplayTitle(record) {
@ -467,26 +517,63 @@ function createApplicantsIndexHtml() {
})) }))
} }
function setViewer(file, link) { function resetViewer(message) {
const viewer = document.getElementById("viewer")
const viewerTitle = document.getElementById("viewer-title")
const viewerSubtitle = document.getElementById("viewer-subtitle")
const viewerDownload = document.getElementById("viewer-download")
if (currentObjectUrl != null) {
URL.revokeObjectURL(currentObjectUrl)
currentObjectUrl = null
}
document.querySelectorAll(".file-link.active").forEach((element) => { document.querySelectorAll(".file-link.active").forEach((element) => {
element.classList.remove("active") element.classList.remove("active")
}) })
if (link) { viewer.removeAttribute("src")
link.classList.add("active") viewerTitle.textContent = "Applicant archive"
viewerSubtitle.textContent = message
viewerDownload.hidden = true
viewerDownload.removeAttribute("href")
viewerDownload.removeAttribute("download")
} }
async function setViewer(file, link) {
if (archive == null) {
throw new Error("No archive loaded")
}
const zipEntry = archive.file(file.relativePath)
const viewer = document.getElementById("viewer") const viewer = document.getElementById("viewer")
const viewerTitle = document.getElementById("viewer-title") const viewerTitle = document.getElementById("viewer-title")
const viewerSubtitle = document.getElementById("viewer-subtitle") const viewerSubtitle = document.getElementById("viewer-subtitle")
const encodedPath = encodeArchivePath(file.relativePath) const viewerDownload = document.getElementById("viewer-download")
const subtitleLink = document.createElement("a")
viewer.src = encodedPath if (zipEntry == null) {
throw new Error("Missing archive entry: " + file.relativePath)
}
const blob = await zipEntry.async("blob")
const previewBlob = blob.type === "" ? new Blob([blob], { type: guessMimeType(file.fileName) }) : blob
if (currentObjectUrl != null) {
URL.revokeObjectURL(currentObjectUrl)
}
currentObjectUrl = URL.createObjectURL(previewBlob)
viewerTitle.textContent = file.fileName viewerTitle.textContent = file.fileName
subtitleLink.href = encodedPath viewerSubtitle.textContent = file.relativePath
subtitleLink.textContent = file.relativePath viewer.src = currentObjectUrl
viewerSubtitle.replaceChildren("Previewing ", subtitleLink) viewerDownload.href = currentObjectUrl
viewerDownload.download = file.fileName
viewerDownload.hidden = false
document.querySelectorAll(".file-link.active").forEach((element) => {
element.classList.remove("active")
})
link.classList.add("active")
} }
function createInfoRow(label, value) { function createInfoRow(label, value) {
@ -501,11 +588,7 @@ function createApplicantsIndexHtml() {
function renderApplicants(applicants) { function renderApplicants(applicants) {
const container = document.getElementById("applicants") const container = document.getElementById("applicants")
const status = document.getElementById("status")
container.replaceChildren() container.replaceChildren()
status.textContent = applicants.length + " applicants"
let firstFileLink = null
applicants.forEach((applicant) => { applicants.forEach((applicant) => {
const card = document.createElement("section") const card = document.createElement("section")
@ -541,16 +624,16 @@ function createApplicantsIndexHtml() {
const link = document.createElement("a") const link = document.createElement("a")
link.className = "file-link" link.className = "file-link"
link.href = encodeArchivePath(file.relativePath) link.href = "#"
link.textContent = file.fileName link.textContent = file.fileName
link.addEventListener("click", (event) => { link.addEventListener("click", async (event) => {
event.preventDefault() event.preventDefault()
setViewer(file, link) try {
}) await setViewer(file, link)
} catch (error) {
if (firstFileLink == null) { setStatus(error.message, true)
firstFileLink = { file, link }
} }
})
item.append(link) item.append(link)
fileList.append(item) fileList.append(item)
@ -565,35 +648,83 @@ function createApplicantsIndexHtml() {
card.append(heading, title, infoList, detailsLink, fileListTitle, fileList) card.append(heading, title, infoList, detailsLink, fileListTitle, fileList)
container.append(card) container.append(card)
}) })
if (firstFileLink != null) {
setViewer(firstFileLink.file, firstFileLink.link)
}
} }
fetch("applicants.csv") async function loadArchive(file) {
.then((response) => { if (typeof JSZip === "undefined") {
if (!response.ok) { throw new Error("JSZip is not available")
throw new Error("Unable to read applicants.csv")
} }
return response.text()
}) setStatus("Loading " + file.name + "...", false)
.then((text) => { const zip = await JSZip.loadAsync(file)
const csvEntry = zip.file("applicants.csv")
if (csvEntry == null) {
throw new Error("Archive does not contain applicants.csv")
}
const text = await csvEntry.async("string")
archive = zip
resetViewer("Select a file on the left to preview it here.")
if (text.trim() === "") {
renderApplicants([])
setStatus("applicants.csv is empty", false)
return
}
const rows = parseCsv(text) const rows = parseCsv(text)
const [header, ...records] = rows const [header, ...records] = rows
if (header == null) {
throw new Error("Unable to parse applicants.csv")
}
const applicants = readApplicants(records.map((row) => Object.fromEntries(header.map((key, index) => [key, row[index] ?? ""])))) const applicants = readApplicants(records.map((row) => Object.fromEntries(header.map((key, index) => [key, row[index] ?? ""]))))
renderApplicants(applicants) renderApplicants(applicants)
setStatus("Loaded " + applicants.length + " applicants from " + file.name, false)
}
document.getElementById("zip-input").addEventListener("change", async (event) => {
const file = event.target.files && event.target.files[0]
if (file == null) {
return
}
try {
await loadArchive(file)
} catch (error) {
archive = null
document.getElementById("applicants").replaceChildren()
resetViewer("Unable to load the selected ZIP file.")
setStatus(error.message, true)
}
}) })
.catch((error) => {
const status = document.getElementById("status") resetViewer("Select the ZIP file, then choose a file on the left to preview it here.")
status.className = "error"
status.textContent = error.message
})
</script> </script>
</body> </body>
</html>` </html>`
} }
async function getArchiveViewerJsZipSource() {
const candidates = [
"lib/jszip.min.js",
"lib/jszip.js"
]
for (const candidate of candidates) {
const response = await fetch(chrome.runtime.getURL(candidate))
if (response.ok) {
return response.text()
}
}
throw new Error("Failed to load archive viewer dependency")
}
function downloadBlob(blob, fileName) { function downloadBlob(blob, fileName) {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const link = document.createElement("a") const link = document.createElement("a")
@ -650,7 +781,7 @@ function createProgressDialog() {
dialog.classList.add("ripper-progress-dialog") dialog.classList.add("ripper-progress-dialog")
title.textContent = "EPAS Ripper" title.textContent = "EPAS Ripper"
status.classList.add("ripper-progress-status") status.classList.add("ripper-progress-status")
status.textContent = "Retrieving applicants list (will take some time)..." status.textContent = "Retrieving applicants list (will take several minutes)..."
list.classList.add("ripper-progress-list") list.classList.add("ripper-progress-list")
summary.classList.add("ripper-progress-summary") summary.classList.add("ripper-progress-summary")
elapsed.classList.add("ripper-progress-elapsed") elapsed.classList.add("ripper-progress-elapsed")
@ -759,7 +890,7 @@ function rip(event) {
btn.disabled = true; btn.disabled = true;
getApplicants() getApplicants()
.then(async ({ aid, applicants, token }) => { .then(async ({ aid, applicants, token }) => {
progressDialog.setStatus("Downloading applicant files...") progressDialog.setStatus("Downloading applicant files (will take a lot of time)...")
applicants.forEach((applicant) => { applicants.forEach((applicant) => {
progressDialog.initializeApplicant(applicant) progressDialog.initializeApplicant(applicant)
}) })
@ -776,7 +907,8 @@ function rip(event) {
} }
})) }))
progressDialog.setStatus("Creating zip archive...") console.log("Preparing zip archive...")
progressDialog.setStatus("Preparing zip archive...")
const zip = new JSZip() const zip = new JSZip()
const successfulApplicants = [] const successfulApplicants = []
@ -798,9 +930,20 @@ function rip(event) {
}) })
}) })
console.log("Creating applicants.csv")
zip.file("applicants.csv", createApplicantsCsv(successfulApplicants)) zip.file("applicants.csv", createApplicantsCsv(successfulApplicants))
console.log("Creating index.html")
zip.file("index.html", createApplicantsIndexHtml()) zip.file("index.html", createApplicantsIndexHtml())
console.log("Adding viewer Javascript to archive")
zip.file("jszip.min.js", await getArchiveViewerJsZipSource())
console.log("Generating zip archive...")
const zipBlob = await zip.generateAsync({ type: "blob" }) const zipBlob = await zip.generateAsync({ type: "blob" })
console.log("Zip archive is ready.")
progressDialog.setStatus("Download ready.") progressDialog.setStatus("Download ready.")
downloadBlob(zipBlob, `applications_${aid}.zip`) downloadBlob(zipBlob, `applications_${aid}.zip`)
btn.textContent = "done" btn.textContent = "done"
@ -822,7 +965,7 @@ function rip(event) {
function install() { function install() {
const titleTag = document.querySelector("scrm-module-title") const titleTag = document.querySelector("scrm-module-title")
if (titleTag != null) { if (titleTag != null && window.location.hash.startsWith("#/job-procedures/record")) {
if (titleTag.querySelector(".ripper-btn") != null) { if (titleTag.querySelector(".ripper-btn") != null) {
return return
} }

View file

@ -19,5 +19,16 @@
"style.css" "style.css"
] ]
} }
],
"web_accessible_resources": [
{
"resources": [
"lib/jszip.js",
"lib/jszip.min.js"
],
"matches": [
"https://personal.uni-graz.at/*"
]
}
] ]
} }