split the content of the viewer to a separate file
This commit is contained in:
parent
ad8999877c
commit
27fe0e55b2
3 changed files with 505 additions and 497 deletions
497
archive-viewer/index.html
Normal file
497
archive-viewer/index.html
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
<!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;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: min(32rem, 42vw);
|
||||||
|
min-width: 22rem;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid #cbd5e1;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
flex: 1;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
border: 0;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-download {
|
||||||
|
color: #0f6cbd;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-download: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">
|
||||||
|
<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>
|
||||||
|
</aside>
|
||||||
|
<main class="content">
|
||||||
|
<div class="content-header">
|
||||||
|
<h1 id="viewer-title">Applicant archive</h1>
|
||||||
|
<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>
|
||||||
|
<iframe id="viewer" class="viewer" title="File preview"></iframe>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="./jszip.min.js"></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) {
|
||||||
|
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 guessMimeType(fileName) {
|
||||||
|
const extension = String(fileName || "").split(".").pop().toLowerCase()
|
||||||
|
const mimeTypes = {
|
||||||
|
csv: "text/csv;charset=utf-8",
|
||||||
|
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) {
|
||||||
|
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 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) => {
|
||||||
|
element.classList.remove("active")
|
||||||
|
})
|
||||||
|
|
||||||
|
viewer.removeAttribute("src")
|
||||||
|
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 viewerTitle = document.getElementById("viewer-title")
|
||||||
|
const viewerSubtitle = document.getElementById("viewer-subtitle")
|
||||||
|
const viewerDownload = document.getElementById("viewer-download")
|
||||||
|
|
||||||
|
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
|
||||||
|
viewerSubtitle.textContent = file.relativePath
|
||||||
|
viewer.src = currentObjectUrl
|
||||||
|
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) {
|
||||||
|
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")
|
||||||
|
container.replaceChildren()
|
||||||
|
|
||||||
|
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 = "#"
|
||||||
|
link.textContent = file.fileName
|
||||||
|
link.addEventListener("click", async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
try {
|
||||||
|
await setViewer(file, link)
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadArchive(file) {
|
||||||
|
if (typeof JSZip === "undefined") {
|
||||||
|
throw new Error("JSZip is not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Loading " + file.name + "...", false)
|
||||||
|
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 [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] ?? ""]))))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
resetViewer("Select the ZIP file, then choose a file on the left to preview it here.")
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
504
content.js
504
content.js
|
|
@ -219,504 +219,14 @@ function createApplicantsCsv(applicantDetailsList) {
|
||||||
return rows.join("\n")
|
return rows.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
function createApplicantsIndexHtml() {
|
async function getArchiveViewerHtmlSource() {
|
||||||
return `<!DOCTYPE html>
|
const response = await fetch(chrome.runtime.getURL("archive-viewer/index.html"))
|
||||||
<html lang="en">
|
|
||||||
<head>
|
if (!response.ok) {
|
||||||
<meta charset="utf-8">
|
throw new Error("Failed to load archive viewer HTML")
|
||||||
<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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
return response.text()
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout {
|
|
||||||
display: flex;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: min(32rem, 42vw);
|
|
||||||
min-width: 22rem;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 1rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
border-right: 1px solid #cbd5e1;
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
flex: 1;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
border: 0;
|
|
||||||
background: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-download {
|
|
||||||
color: #0f6cbd;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-download: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">
|
|
||||||
<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>
|
|
||||||
</aside>
|
|
||||||
<main class="content">
|
|
||||||
<div class="content-header">
|
|
||||||
<h1 id="viewer-title">Applicant archive</h1>
|
|
||||||
<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>
|
|
||||||
<iframe id="viewer" class="viewer" title="File preview"></iframe>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<script src="jszip.min.js"></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) {
|
|
||||||
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 guessMimeType(fileName) {
|
|
||||||
const extension = String(fileName || "").split(".").pop().toLowerCase()
|
|
||||||
const mimeTypes = {
|
|
||||||
csv: "text/csv;charset=utf-8",
|
|
||||||
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) {
|
|
||||||
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 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) => {
|
|
||||||
element.classList.remove("active")
|
|
||||||
})
|
|
||||||
|
|
||||||
viewer.removeAttribute("src")
|
|
||||||
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 viewerTitle = document.getElementById("viewer-title")
|
|
||||||
const viewerSubtitle = document.getElementById("viewer-subtitle")
|
|
||||||
const viewerDownload = document.getElementById("viewer-download")
|
|
||||||
|
|
||||||
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
|
|
||||||
viewerSubtitle.textContent = file.relativePath
|
|
||||||
viewer.src = currentObjectUrl
|
|
||||||
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) {
|
|
||||||
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")
|
|
||||||
container.replaceChildren()
|
|
||||||
|
|
||||||
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 = "#"
|
|
||||||
link.textContent = file.fileName
|
|
||||||
link.addEventListener("click", async (event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
try {
|
|
||||||
await setViewer(file, link)
|
|
||||||
} catch (error) {
|
|
||||||
setStatus(error.message, true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadArchive(file) {
|
|
||||||
if (typeof JSZip === "undefined") {
|
|
||||||
throw new Error("JSZip is not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("Loading " + file.name + "...", false)
|
|
||||||
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 [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] ?? ""]))))
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
resetViewer("Select the ZIP file, then choose a file on the left to preview it here.")
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getArchiveViewerJsZipSource() {
|
async function getArchiveViewerJsZipSource() {
|
||||||
|
|
@ -945,7 +455,7 @@ function rip(event) {
|
||||||
zip.file("applicants.csv", createApplicantsCsv(successfulApplicants))
|
zip.file("applicants.csv", createApplicantsCsv(successfulApplicants))
|
||||||
|
|
||||||
console.log("Creating index.html")
|
console.log("Creating index.html")
|
||||||
zip.file("index.html", createApplicantsIndexHtml())
|
zip.file("index.html", await getArchiveViewerHtmlSource())
|
||||||
|
|
||||||
console.log("Adding viewer Javascript to archive")
|
console.log("Adding viewer Javascript to archive")
|
||||||
zip.file("jszip.min.js", await getArchiveViewerJsZipSource())
|
zip.file("jszip.min.js", await getArchiveViewerJsZipSource())
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
{
|
{
|
||||||
"resources": [
|
"resources": [
|
||||||
|
"archive-viewer/index.html",
|
||||||
"lib/jszip.js",
|
"lib/jszip.js",
|
||||||
"lib/jszip.min.js"
|
"lib/jszip.min.js"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue