split the content of the viewer to a separate file

This commit is contained in:
Gaspard Jankowiak 2026-06-05 12:55:40 +02:00 committed by gapato
commit 27fe0e55b2
3 changed files with 505 additions and 497 deletions

497
archive-viewer/index.html Normal file
View 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>

View file

@ -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())

View file

@ -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"
], ],