Select Git revision
rendering.ts
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
rendering.ts 25.50 KiB
import { FinishedTranscriptMeta, Resolution, Todo } from "./parsing";
import { Attendance } from "./attendance";
export function renderContainerToAlert(context: String, title?: String) {
return function (tokens, idx) {
return tokens[idx].nesting === 1
? `<div class="alert ${context}" role="alert"">\n${
title === undefined
? ""
: `<h5 class="alert-heading">${title}</h5>\n`
}`
: "</div>\n";
};
}
export function renderResolutionToHtml(resolution: Resolution): string {
return `
<div class="card mb-3 text-center" id="${resolution.number}">
<div class="card-header" >Beschluss <code>${resolution.number}</code></div>
<div class="card-body">
<h5 class="card-title"></h5>
<div class="card-text">${resolution.html}</div>
<div class="container container-sm">
<div class="row align-items-center">
<div class="col">
<span class="p-1 border-bottom border-success border-2">
Ja: ${resolution.votes.yes}
</span>
</div>
<div class="col">
<span class="p-1 border-bottom border-danger border-2">
Nein: ${resolution.votes.no}
</span>
</div>
<div class="col">
<span class="p-1 border-bottom border-info border-2">
Enthaltung: ${resolution.votes.abstention}
</span>
</div>
</div>
</div>
</div>
<div class="card-footer ${
resolution.accepted ? "bg-success" : "bg-danger"
} bg-opacity-25">
${resolution.result}
</div>
${
resolution.provisional
? `<div class="card-footer bg-warning bg-opacity-25">
<strong class="font-bold">Kommissarisch, weil:</strong> ${resolution.provisional}
</div>`
: ""
}
</div>`;
}
export function renderTodoToHtml(todo: Todo): string {
return `
<div class="card mb-3 text-center">
<div class="card-header">Neues To-Do</div>
<div class="card-body">
<h5 class="card-title"></h5>
<div class="card-text">${todo.html}</div>
</div>
<div class="card-footer bg-info bg-opacity-25">
<div class="bg-opacity-100">
${
todo.team
? `<span class="badge text-bg-info mb-1 me-1">Team: ${todo.team}</span>`
: ""
}
${
todo.people
? `<span class="badge text-bg-info mb-1 me-1">Zuständig: ${todo.people}</span>`
: ""
}
</div>
</div>
</div>`;
}
export function generateFooterPartialHtml(depth: number = 0): string {
const depthTraversal = new Array(depth).fill("..").join("/");
return `
<footer class="my-5 container text-center">
<p>
<a href="https://fsinfo.cs.tu-dortmund.de/impressum">Impressum</a>
|
<a href="https://fsinfo.cs.tu-dortmund.de/datenschutz/start">Datenschutz</a>
</p>
<p class="text-muted">
<small>
<a href="./${depthTraversal}/index.json">JSON</a> <a href="./${depthTraversal}/index.xml">RSS-Feed</a>
</small>
</p>
<p class="text-muted">
<small>
HTML generiert: ${new Date().toLocaleString("de-DE")}
</small>
</p>
</footer>`;
}
export function renderTranscriptPageHtml(
transcriptHtml: string,
meta: any,
): string {
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${meta.title}</title>
<link href="../../bootstrap.min.css" rel="stylesheet">
<style>
div:target {
border-width: 0.4rem;
border-color: #f4c4aa;
scroll-margin-top: 4rem;
}
/* Bootstrap <p> style. Necessary, since our internal architecture changed */
.card-text:has(.resolution, .todo) {
margin-top: 0;
margin-bottom: 1rem;
}
h1 {
border-top: solid 0.1rem;
margin-top: 2rem;
padding-top: 1rem;
}
@media print {
.hide-in-print {
display: none !important;
}
}
</style>
<script>
window.addEventListener("beforeprint", () => document.getElementById("metadata").open = true );
window.addEventListener("afterprint", () => document.getElementById("metadata").open = false );
</script>
</head>
<body class="bg-body">
<div>
<header class="hide-in-print">
<nav class="navbar navbar-expand bg-body-tertiary">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="../..">Protokolle</a>
</li>
<li class="nav-item">
<a class="nav-link" href="../../resolutions/">Beschlüsse</a>
</li>
<!-- WIP:
<li class="nav-item">
<a class="nav-link" href="../../relevant/">Relevante Beschlüsse</a>
</li>
-->
<li class="nav-item">
<a class="nav-link" href="../../attendance/">Anwesenheit</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://gitlab.fachschaften.org/search?group_id=29&project_id=77&repository_ref=master&scope=blobs&search=">Suche</a>
</li>
</ul>
</nav>
</header>
</div>
<div class="container mt-2 mb-2">
<div class="bg-body-tertiary">
<article class="p-4">
<div class="p-2 rounded text-center" style="background-color: #DDDDDD">
Fehler in diesem Protokoll können von Gremien-Mitgliedern
<a href="https://gitlab.fachschaften.org/tudo-fsinfo/fsr/sitzungen/-/merge_requests">direkt per Merge Request</a>
behoben werden. Andernfalls bitte an die Protokollanten (siehe <a href="https://oh14.de/fsr">oh14.de/fsr</a>) wenden.
</div>
<h1 class="mb-4">${meta.title}</h1>
<div class="card mb-4">
<details class="card-body" id="metadata">
<summary class="hide-in-print"><b>Metadaten</b></summary>
<p>
Start: ${meta.start}, Ende: ${meta.end}
<br />
Sitzungsleitung: ${meta.head}, Protokoll: ${meta.author}
</p>
<div class="row">
<div class="col">
<b>Anwesend:</b>
<ul>
${meta.present.map((p) => "<li>" + p + "</li>").join("\n")}
</ul>
</div>
<div class="col">
<b>Abwesend:</b>
<ul>
${meta.absent.map((p) => "<li>" + p + "</li>").join("\n")}
</ul>
</div>
<div class="col">
<b>Gäste:</b>
<ul>
${meta.guests.map((p) => "<li>" + p + "</li>").join("\n")}
</ul>
</div>
</div>
</details>
</div>
<div class="ms-2 me-2">
<p><i>Die Sitzung wird um ${meta.start} eröffnet.</i></p>
${transcriptHtml}
<p><i>Die Sitzung wird um ${meta.end} geschlossen.</i></p>
</div>
</article>
</div>
</div>
${generateFooterPartialHtml(2)}
</body>
</html>`;
}
function getTranscriptFilename(t: FinishedTranscriptMeta) {
return (
[
"fsr-sitzung",
typeof t.number === "number"
? `${t.number}`.padStart(3, "0")
: null,
`${t.date}`,
t.label && t.spec_version === -1
? `${t.label.toLowerCase()}`
: null,
]
.filter((x) => x !== null)
.join("-") + (t.spec_version === -1 ? ".pdf" : "")
);
}
function getTranscriptTitle(t: FinishedTranscriptMeta) {
const interjection =
(typeof t.number === "number" ? ` ${t.number}` : "")
+ (t.label ? ` ${t.label}` : "");
return `FSR-Sitzung${interjection} (${t.date})`;
}
export function generateIndexHtml(
transcripts: FinishedTranscriptMeta[],
): string {
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FSR-Protokolle</title>
<link href="./bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="">
<header>
<nav class="navbar navbar-expand bg-body-tertiary">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="#">Protokolle</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./resolutions/">Beschlüsse</a>
</li>
<!-- WIP:
<li class="nav-item">
<a class="nav-link" href="./relevant/">Relevante Beschlüsse</a>
</li>
-->
<li class="nav-item">
<a class="nav-link" href="./attendance/">Anwesenheit</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://gitlab.fachschaften.org/search?group_id=29&project_id=77&repository_ref=master&scope=blobs&search=">Suche</a>
</li>
</ul>
</nav>
</header>
</div>
<div class="container mt-2 mb-2">
<ul>
${transcripts
.map((t) => {
const title = getTranscriptTitle(t);
return `<li>${!t.no_link ? `<a href="./protokolle/${getTranscriptFilename(t)}">${title}</a>` : title}</li>`;
})
.join("\n")}
</ul>
</div>
${generateFooterPartialHtml(0)}
</body>
</html>`;
}
export function generateResolutionsHtml(
transcripts: FinishedTranscriptMeta[],
modifiedBy: Map<string, Resolution[]>,
revokedBy: Map<string, Resolution[]>,
note?: string,
): string {
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FSR-Protokolle</title>
<link href="../bootstrap.min.css" rel="stylesheet">
<style>
tr:target {
border-width: 0.4rem;
border-color: #f4c4aa;
scroll-margin-top: 4rem;
}
details > summary {
color: #a7a8aa;
}
details[open] > summary {
color: #a7a8aa;
}
</style>
</head>
<body>
<svg style="display: none" version="2.0">
<defs>
<symbol id="clipboard-icon" viewbox="0 0 16 16" fill="currentColor">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</symbol>
</defs>
<use href="#clipboard-icon"/>
</svg>
<div class="">
<header>
<nav class="navbar navbar-expand bg-body-tertiary">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="../">Protokolle</a>
<i class="fa fa-link"></i> </li>
<li class="nav-item">
<a class="nav-link" href="../resolutions/">Beschlüsse</a>
</li>
<!-- WIP:
<li class="nav-item">
<a class="nav-link" href="../relevant/">Relevante Beschlüsse</a>
</li>
-->
<li class="nav-item">
<a class="nav-link" href="../attendance/">Anwesenheit</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://gitlab.fachschaften.org/search?group_id=29&project_id=77&repository_ref=master&scope=blobs&search=">Suche</a>
</li>
</ul>
</nav>
</header>
</div>
${note ?? ""}
<div class="container mt-2 mb-2">
<table class="table">
<thead>
<tr>
<th>
Datum
</th>
<th>
Nummer
</th>
<th>
Text
</th>
<th>
Ja/Nein/Enth.
</th>
<th>
Ergebnis
</th>
<th>
⚓️
</th>
</tr>
</thead>
<tbody>
${transcripts
.map((t) => {
const transcriptFilename = !t.no_link
? getTranscriptFilename(t)
: null;
const resolutions = t.resolutions ?? [];
return resolutions.map((r) =>
generateResolutionRowHtml(
r,
transcriptFilename,
t.date,
modifiedBy.get(r.number),
revokedBy.get(r.number),
),
);
})
.flat()
.join("\n")}
</tbody>
</table>
</div>
${generateFooterPartialHtml(0)}
</body>
</html>`;
}
function generateResolutionRowHtml(
resolution: Resolution,
transcriptFileName?: string,
date: string,
modifiedBy?: Resolution[],
revokedBy?: Resolution[],
): string {
function samePageResolutionLink(other: Resolution | string) {
const number = other?.number ?? other;
// TODO: check if link exists.
const link = `<a href=${"#" + number}>${number}</a>`;
return typeof other === "string" || other.isActive !== false
? link
: `<del>${link}</del>`;
}
function linkResolutions(name: string, others: Resolution[] | string[]) {
function genInnerHTML(name: string, others: Resolution[] | string[]) {
return `<strong>${name}:</strong> ${others.map(samePageResolutionLink).join(", ")}`;
}
if (others && others.length > 0)
return `<p>${genInnerHTML(name, others)}</p>`;
else return "";
}
let linkedNumber =
transcriptFileName !== null
? `<a href="../protokolle/${transcriptFileName}/#${resolution.number}">${resolution.number}</a>`
: `${resolution.number}`;
let votes;
if (resolution.votes === undefined) {
votes = "";
} else if (
resolution.votes.yes === undefined
|| resolution.votes.no === undefined
) {
votes = Object.keys(resolution.votes)
.map(
(r) =>
`${r === "abstention" ? "Enth." : r}: ${resolution.votes[r]}`,
)
.join("<br>");
} else {
votes = `${resolution.votes.yes} / ${resolution.votes.no} / ${resolution.votes.abstention}`;
}
return `<tr id="${resolution.number}">
<td>
${date}
</td>
<td>
${linkedNumber}
</td>
<td>
<div style="${resolution.isActive === false ? "color: red" : ""}">
${resolution.html}
${linkResolutions("Modifiziert", resolution.modifies)}
${linkResolutions("Widerruft", resolution.revokes)}
</div>
${linkResolutions("Modifiziert durch", modifiedBy)}
${linkResolutions("Widerrufen durch", revokedBy)}
${
resolution.provisional
? `<p><strong>Kommissarisch, weil:</strong> ${resolution.provisional}</p>`
: ""
}
${
typeof resolution.money_granted === "string"
? generateHledgerDetailsHtml(resolution)
: ""
}
${
resolution.note
? `<p><strong>Notiz:</strong> ${resolution.note}</p>`
: ""
}
</td>
<td>
${votes}
</td>
<td>
${resolution.result ?? "Unbekannt"}
</td>
<td>
<a href="#${resolution.number}">
🔗
</a>
</td>
</tr>`;
}
export function generateTranscriptsRssXml(
transcripts: FinishedTranscriptMeta[],
rootUrl: string | null = null,
): string {
return `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Protokolle</title>
${transcripts
.map(
(t) =>
`<item>${
!t.no_link
? `<link>${rootUrl ?? "."}/protokolle/${getTranscriptFilename(t)}</link>`
: ``
}<description>${getTranscriptTitle(t)}</description><title>${getTranscriptTitle(t)}</title><pubDate>${t.date}</pubDate></item>`,
)
.join("\n ")}
</channel>
</rss>`;
}
function generateHledgerDetailsHtml(resolution: Resolution): string {
const hledgerText = `account Gebunden:${resolution.number}
${resolution.date} ${resolution.number}: beschlossen
[Gebunden:${resolution.number}] ${resolution.money_granted}
[Vermögen:FSR:Gebunden]`;
return `<details><summary><small class="text-body-secondary fw-light">Hledger-Statement für die Buchhaltung</small></summary><pre><code id="reso-text-${resolution.number}">${hledgerText}</code></pre><button class="btn btn-outline-primary btn-sm" onclick="navigator.clipboard.writeText(document.getElementById(\'reso-text-${resolution.number}\').innerText)"><svg width="16" height="16" version="2.0"><use href="#clipboard-icon" /></svg> Hledger-Statement kopieren</button></details>`;
}
export function generateAttendanceHtmlWrapper(
attendanceParts: [
Map<string, Attendance>,
string,
number,
number | undefined,
][],
): string {
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FSR-Anwesenheit</title>
<link href="../bootstrap.min.css" rel="stylesheet">
<style>
.percentage-bar {
display: flex;
height: 15px;
width: 100%;
border-radius: 8px;
overflow: hidden;
border: 1px solid #ccc;
margin-top: 5px;
}
body:has(#grouped-bar-toggle:checked) .grouped-bar {
visibility: visible;
position: static;
}
body:has(#grouped-bar-toggle:checked) .ungrouped-bar {
visibility: hidden;
position: absolute;
}
.grouped-bar {
visibility: hidden;
position: absolute;
}
.ungrouped-bar {
visibility: visible;
position: static;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
const getCellValue = (tr, idx) => tr.children[idx].innerText || tr.children[idx].textContent;
const comparer = (idx, asc) => (a, b) => {
const v1 = getCellValue(a, idx);
const v2 = getCellValue(b, idx);
const f1 = parseFloat(v1);
const f2 = parseFloat(v2);
if (!isNaN(f1) && !isNaN(f2)) {
return (f1 - f2) * (asc ? 1 : -1);
} else {
return v1.toString().localeCompare(v2) * (asc ? 1 : -1);
}
};
const sortTable = (table, columnIndex, asc) => {
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort(comparer(columnIndex, asc));
rows.forEach(row => tbody.appendChild(row));
// Clean up previous sort indicators
table.querySelectorAll('th').forEach(th => th.classList.remove('asc', 'desc'));
const th = table.querySelectorAll('th')[columnIndex];
th.classList.toggle('asc', asc);
th.classList.toggle('desc', !asc);
};
// Enable click-to-sort
document.querySelectorAll('table').forEach(table => {
table.querySelectorAll('th').forEach((th, idx) => {
th.addEventListener('click', function () {
const isAsc = !th.classList.contains('asc');
sortTable(table, idx, isAsc);
});
});
// Sort third column descending by default on page load
sortTable(table, 2, false);
});
});
</script>
</head>
<body>
<div class="">
<header>
<nav class="navbar navbar-expand bg-body-tertiary">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="../">Protokolle</a>
<i class="fa fa-link"></i> </li>
<li class="nav-item">
<a class="nav-link" href="../resolutions/">Beschlüsse</a>
</li>
<!-- WIP:
<li class="nav-item">
<a class="nav-link" href="../relevant/">Relevante Beschlüsse</a>
</li>
-->
<li class="nav-item">
<a class="nav-link" href="../attendance/">Anwesenheit</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://gitlab.fachschaften.org/search?group_id=29&project_id=77&repository_ref=master&scope=blobs&search=">Suche</a>
</li>
</ul>
<div class="ms-auto d-flex-flex align-items-center form-check me-3">
<input class="form-check-input me-2" type="checkbox" id="grouped-bar-toggle" value="" checked />
<label class="form-check-label" for="grouped-bar-toggle">
Group attendance bar
</label>
</div>
</nav>
</header>
</div>
${attendanceParts.map(([attendanceMap, summary, from, to]) => generateAttendanceHtml(attendanceMap, summary, from, to)).join("\n")}
${generateFooterPartialHtml(0)}
</body>
</html>`;
}
export function generateAttendanceHtml(
attendanceMap: Map<string, Attendance>,
summary: string,
from: number,
to?: number,
): string {
const open = to === undefined;
return `
<details class="border"${open ? " open" : ""}>
<summary class="fs-4 mx-3 p-2">${summary} von Sitzung ${from} bis ${to === undefined ? "heute" : "Sitzung " + to}</summary>
<div class="container my-2">
<table class="table table-striped">
<thead>
<tr>
<th>
Name
</th>
<th>
Anwesehnheit
</th>
<th>
Vor Ort
</th>
<th>
Nicht da (entschuldigt)
</th>
<th>
Nicht da (unentschuldigt)
</th>
<th>
Anwesenheitsquotient
</th>
</tr>
</thead>
<tbody>
${[...attendanceMap.entries()].map(([k, v]) => generateAttendanceRowHtml(k, v)).join("")}
</tbody>
</table>
</div>
</details>`;
}
function generateAttendanceRowHtml(name: string, data: Attendance): string {
return `
<tr>
<td> ${name} </td>
<td> ${generateGroupedPercentageBar(data)} ${generateUngroupedPercentageBar(data)} </td>
<td> ${data.present} </td>
<td> ${data.noshow_excused} </td>
<td> ${data.noshow}</td>
<td> ${((data.present * 100) / data.sum()).toFixed(1)}% </td>
</tr>`;
}
function generateUngroupedPercentageBar(data: Attendance): string {
const width = 100 / data.sum();
const tagged: { value: number; color: string }[] = [
...data.presents.map((value) => ({ value, color: "darkgreen" })),
...data.noshows.map((value) => ({ value, color: "red" })),
...data.noshow_excuseds.map((value) => ({ value, color: "yellow" })),
];
// Sort by number
tagged.sort((a, b) => a.value - b.value);
var barHtml = '<div class="percentage-bar ungrouped-bar">';
// Iterate in order
for (const { color } of tagged) {
barHtml += `<div style="width: ${width}%;background-color: ${color}"></div>`;
}
barHtml += "</div>";
return barHtml;
}
function generateGroupedPercentageBar(data: Attendance): string {
return `
<div class="percentage-bar grouped-bar">
<div style="width: ${(data.present * 100) / data.sum()}%;background-color: darkgreen"></div>
<div style="width: ${(data.noshow_excused * 100) / data.sum()}%;background-color: yellow"></div>
<div style="width: ${(data.noshow * 100) / data.sum()}%;background-color: red"></div>
</div>`;
}