import { FinishedTranscriptMeta, Resolution, Todo } from "./parsing"; import { Attendance } from "./attendance"; import Token from "markdown-it/lib/token.mjs"; export function renderContainerToAlert(context: String, title?: String) { return function (tokens: Token[], idx: number) { 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, options: CliOptions, ): 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> --> ${ options.attendance != "none" ? `<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: string) => "<li>" + p + "</li>").join("\n")} </ul> </div> <div class="col"> <b>Abwesend:</b> <ul> ${meta.absent.map((p: string) => "<li>" + p + "</li>").join("\n")} </ul> </div> <div class="col"> <b>Gäste:</b> <ul> ${meta.guests.map((p: string) => "<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[], options: CliOptions, ): 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> --> ${ options.attendance != "none" ? `<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[]>, options: CliOptions, 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> --> ${ options.attendance != "none" ? `<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) : undefined; const resolutions = t.resolutions ?? []; return resolutions.map((r) => generateResolutionRowHtml( r, t.date, transcriptFilename, modifiedBy.get(r.number), revokedBy.get(r.number), ), ); }) .flat() .join("\n")} </tbody> </table> </div> ${generateFooterPartialHtml(0)} </body> </html>`; } function generateResolutionRowHtml( resolution: Resolution, date: string, transcriptFileName?: string, modifiedBy?: Resolution[], revokedBy?: Resolution[], ): string { function samePageResolutionLink(other: Resolution | string) { const number = typeof other === "string" ? other : other.number; 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[] | undefined, ) { 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 !== undefined ? `<a href="../protokolle/${transcriptFileName}/#${resolution.number}">${resolution.number}</a>` : `${resolution.number}`; let votes: string; 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" /> <meta name="robots" content="noindex" /> <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>`; }