index.html first attempt
This commit is contained in:
parent
745a5d3812
commit
ebc3b325e3
1 changed files with 409 additions and 6 deletions
415
content.js
415
content.js
|
|
@ -138,7 +138,8 @@ const CSV_FIELDS = [
|
|||
"comment",
|
||||
"hearing_videos_link",
|
||||
"dir_name",
|
||||
"details_url"
|
||||
"details_url",
|
||||
"files_json"
|
||||
]
|
||||
|
||||
function getApplicantDirectoryName(applicantDetails) {
|
||||
|
|
@ -149,6 +150,29 @@ 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)
|
||||
|
|
@ -158,6 +182,10 @@ function getApplicantCsvValue(applicantDetails, fieldName) {
|
|||
return getApplicantDetailsUrl(applicantDetails.id)
|
||||
}
|
||||
|
||||
if (fieldName === "files_json") {
|
||||
return JSON.stringify(getApplicantArchiveFiles(applicantDetails))
|
||||
}
|
||||
|
||||
if (fieldName === "job_procedure") {
|
||||
return applicantDetails.job_procedure?.date_entered ?? ""
|
||||
}
|
||||
|
|
@ -191,6 +219,381 @@ function createApplicantsCsv(applicantDetailsList) {
|
|||
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")
|
||||
|
|
@ -385,18 +788,18 @@ function rip(event) {
|
|||
const applicantDetails = result.value
|
||||
const dirName = getApplicantDirectoryName(applicantDetails)
|
||||
const applicantDir = zip.folder(dirName)
|
||||
const files = applicantDetails.application_files ?? []
|
||||
const archiveFiles = getApplicantArchiveFiles(applicantDetails)
|
||||
|
||||
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)
|
||||
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`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue