Compare commits

...

24 Commits

Author SHA1 Message Date
80b0eb9448 Actually generating valid templates now 2025-10-08 09:00:13 +02:00
14c347b2f1 Now asking whether to omit the Makefile 2025-10-07 16:42:01 +02:00
85e286c2c5 The generator now produces a working template! 2025-10-07 16:04:57 +02:00
10e7d50501 Repair skill issue 2025-10-07 14:31:01 +02:00
9983b98da0 Disabled pulling of wip changes from upstream 2025-10-07 13:51:33 +02:00
f85d557916 Switched to upstream's build system in the build script 2025-10-07 13:05:01 +02:00
cc7a3908f6 Study programme step is automatically skipped for faculties without programmes 2025-10-07 12:09:00 +02:00
670dd9596e Added boilerplate acknowledgement and keywords 2025-10-07 12:03:40 +02:00
48af642e4f BinaryInputBuilder was a bit fucky wucky without the update handler 2025-10-07 12:02:21 +02:00
99c876c48c Added the abstract into the template 2025-10-07 11:36:53 +02:00
264299a7a8 Programme now actually works correctly 2025-10-07 11:32:11 +02:00
e2e0f45588 Added study programme selector 2025-10-07 11:15:51 +02:00
ae1eaa3c26 STAG is a bit busted 2025-10-07 11:04:41 +02:00
49e5f8b9b7 Now generating the list of programmes 2025-10-07 08:53:00 +02:00
62f190c5b1 Added script for generating the archive 2025-10-06 21:16:08 +02:00
3c6dd62919 This sounds a bit better 2025-10-06 20:58:44 +02:00
c1e7885677 Added thesis assignment format 2025-10-06 20:55:20 +02:00
155fabc4b9 Added proper pronouns support for English variants 2025-10-06 20:39:07 +02:00
4705b6ec68 Added thesis language 2025-10-06 20:35:26 +02:00
4d40632db0 Text inputs now actually remember their state 2025-10-06 20:21:57 +02:00
1e03405ebc Added thesis title 2025-10-06 19:54:10 +02:00
50f2c9a647 Added hints wherever text fields are optional 2025-10-06 19:45:18 +02:00
7295dfb882 Template generation is now split up into its own function 2025-10-06 19:41:49 +02:00
6d1554fa43 Entries now have a finalize step to fill in defaults 2025-10-06 19:40:35 +02:00
9 changed files with 506 additions and 73 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
template_upstream
archive
template.zip
programmes.js

View File

@@ -1,8 +1,8 @@
# THIS WILL NOT WORK IF YOU RUN IT YOURSELF!
# TUL Typst template generator
The generator expects `template.zip` with the full template included, it just injects its own `thesis.typ` into it.
Before you host this yourself, make sure to run the `build.sh` script to create the template archive. Without it, the generator will not work.
I'll make the script for generating this archive when I get back from work today lol.
Make sure that you have the `csvkit` and `jq` packages installed on your Arch system, they are needed to generate the list of study programmes.
[Here's a running instance of this generator, just to get a rough idea.](https://internal.prochazkaml.eu/tultemplategen)

BIN
assignment.pdf Normal file

Binary file not shown.

47
build.sh Executable file
View File

@@ -0,0 +1,47 @@
mkdir -p archive
echo -n "const programmes = " > programmes.js
curl "https://stag.tul.cz/StagPortletsJSR168/ProhlizeniPrint?stateClass=cz.zcu.stag.portlets168.prohlizeni.browser.BrowserFakultaSearchState&wservice=programy/getStudijniProgramy&outputFormat=CSV&pouzePlatne=true&lang=cs" | csvjson -e cp1250 | jq 'group_by(.fakulta | ascii_downcase) | [.[] | {
key: .[0].fakulta | ascii_downcase | if . == "fe" then "ef" else . end | if . == "fa" then "fua" else . end,
value: [.[] | {
code: .kod | ascii_upcase,
type: .typ | ascii_downcase // .titulZkr // .titul,
title: .titulZkr // .titul // "bez titulu",
form: .forma | ascii_downcase,
year: .platnyOd | round,
cs_name: .nazevCz // .nazev,
en_name: .nazevAn // .nazev
}]
}] | from_entries' >> programmes.js
TEMPLATE=template_upstream
if [ ! -d $TEMPLATE ]; then
git clone git@gordon.zumepro.cz:tul/tultemplate2 $TEMPLATE
else
cd $TEMPLATE
git fetch --tags
cd ..
fi
cd $TEMPLATE
git checkout $(git describe --tags "$(git rev-list --tags --max-count=1)")
cd ..
TEMPLATE_PACKDIR=$TEMPLATE/pack/bundle
rm -r $TEMPLATE_PACKDIR
cd $TEMPLATE
make bundle
cd ..
cp -r \
$TEMPLATE_PACKDIR/. \
assignment.pdf \
archive
rm template.zip
cd archive
zip -r ../template.zip *
cd ..

220
data.js
View File

@@ -13,12 +13,69 @@ let steps = {
type: ""
}
},
language: {
title: "V jakém jazyce budete práci tvořit?",
layout: language_layout,
result: {
lang: "cs"
},
finalize: (result) => {
result.is_cs = result.lang === "cs";
result.is_en = result.lang === "en";
}
},
thesis_title: {
title: "Jak se bude práce jmenovat?",
layout: thesis_title_layout,
result: {
cs: "",
en: ""
},
finalize: (result) => {
if(result.cs === "") {
result.cs = "SEM DOPLŇTE ČESKÝ NÁZEV PRÁCE";
}
if(result.en === "") {
result.en = "SEM DOPLŇTE ANGLICKÝ NÁZEV PRÁCE";
}
}
},
author_info: {
title: "Vaše údaje",
layout: author_info_layout,
result: {
name: "",
pronouns: ""
},
finalize: (result) => {
if(result.name === "") {
result.name = "SEM DOPLŇTE VAŠE JMÉNO";
}
}
},
programme: {
condition: () => programmes[steps.faculty.result.name] !== undefined,
title: "Který program studujete?",
layout: programme_layout,
result: {
faculty: "", // used for checking whether to clear
idx: -1 // -1 = none, 0+ = index into programmes[faculty]
},
finalize: (result) => {
if(result.idx == -1) {
result.has_programme = false;
return
}
let programme = programmes[steps.faculty.result.name][result.idx];
result.has_programme = true;
result.programme = programme.code + " " + (
steps.language.result.lang == "cs" ?
programme.cs_name :
programme.en_name
);
}
},
collaborators: {
@@ -29,8 +86,45 @@ let steps = {
supervisor_name: "",
has_consultant: false,
consultant_name: ""
},
finalize: (result) => {
if(result.supervisor_name === "") {
result.supervisor_name = "SEM DOPLŇTE JMÉNO VEDOUCÍHO PRÁCE";
}
if(result.consultant_name === "") {
result.consultant_name = "SEM DOPLŇTE JMÉNO KONZULTANTA PRÁCE";
}
}
},
assignment: {
title: "Jak vložit zadání?",
layout: assignment_layout,
result: {
format: "empty"
},
finalize: (result) => {
result.include_pdf = result.format === "external";
}
},
other_details: {
title: "Ostatní detaily",
layout: other_details_layout,
result: {
has_keywords: true,
has_acknowledgement: true
}
},
toolchain: {
title: "Jak budete používat Typst?",
layout: toolchain_layout,
result: {
toolchain: "online"
},
finalize: (result) => {
result.include_makefile = result.toolchain === "local";
}
}
}
const faculties = {
@@ -87,7 +181,16 @@ const theses = {
}
}
const pronounss = {
const languages = {
"cs": {
name: "Čeština"
},
"en": {
name: "Angličtina"
}
}
const pronouns_cs = {
"masculine": {
name: "Mužský rod"
},
@@ -99,46 +202,98 @@ const pronounss = {
}
}
const pronouns_en = {
"me": {
name: "Jednotné čislo"
},
"we": {
name: "Množné číslo"
}
}
const assignment_formats = {
"empty": {
name: "Vložit do dokumentu prázdnou stránku.\nTuto stránku poté nahradíte podepsaným zadáním před vazbou dokumentu."
},
"external": {
name: "Vložit do dokumentu zadání ve formě PDF."
}
}
const toolchains = {
"online": {
name: "Pomocí webového editoru Typstu"
},
"local": {
name: "Pomocí příkazové řádky"
}
}
const typst_header = `#import "template/template.typ": *
#show: tultemplate2.with(
faculty: "{faculty.name}",
document: "{thesis_type.type}",
title: (cs: "Návod na použití Typst TUL šablony"),
lang: "{language.lang}",
title: (cs: "{thesis_title.cs}", en: "{thesis_title.en}"),
specialization: ({language.lang}: "SEM DOPLŇTE SPECIALIZACI"),
<other_details.has_keywords: keywords: (
cs: ("DOPLŇTE", "SEM", "KLÍČOVÁ", "SLOVA"),
en: ("INSERT", "KEYWORDS", "HERE")
),
><other_details.has_acknowledgement: acknowledgement: (
<language.is_cs:cs: "
Rád bych poděkoval všem, kteří přispěli ke vzniku tohoto dílka.
"><language.is_en:en: "
I would like to acknowledge everyone who contributed to the creation of this fine piece of work.
">
),
> abstract: (
cs: "
Sem vyplňte abstrakt své práce v češtině.
",
en: "
Insert the abstract of your theses in English here.
"
),
author: "{author_info.name}",
author_pronouns: "{author_info.pronouns}",
<collaborators.has_supervisor: supervisor: "{collaborators.supervisor_name}",
><collaborators.has_consultant: consultant: "{collaborators.consultant_name}",
> citations: "citations.bib",
assignment: "zadani.pdf"
><programme.has_programme: programme: ({language.lang}: "{programme.programme}"),
><assignment.include_pdf: assignment: "assignment.pdf",
> citations: "citations.bib"
)
= Nadpis
skibidi
`;
async function generate_zip() {
let assets_zip = await fetch("template.zip");
let out_zip = await new JSZip().loadAsync(await assets_zip.blob());
function generate_template_header() {
let out = typst_header;
for(const step_key in steps) {
console.log(step_key);
for(const key in steps[step_key].result) {
let result = steps[step_key].result;
if(steps[step_key].finalize) {
steps[step_key].finalize(result);
}
for(const key in result) {
console.log("- " + key);
const search_condition = "<" + step_key + "." + key + ":";
const condition_pos = out.search(search_condition);
if(condition_pos >= 0) {
while(true) {
const condition_pos = out.search(search_condition);
if(condition_pos < 0) break;
const condition_end_pos = out.slice(condition_pos).search(">");
if(condition_end_pos >= 0) {
const head = out.slice(0, condition_pos);
const meat = steps[step_key].result[key] ? out.slice(condition_pos + search_condition.length, condition_pos + condition_end_pos) : "";
const meat = result[key] ? out.slice(condition_pos + search_condition.length, condition_pos + condition_end_pos) : "";
const tail = out.slice(condition_pos + condition_end_pos + 1);
out = head + meat + tail;
@@ -146,11 +301,14 @@ async function generate_zip() {
}
const search_replacement = "{" + step_key + "." + key + "}";
const replacement_pos = out.search(search_replacement);
if(replacement_pos >= 0) {
while(true) {
const replacement_pos = out.search(search_replacement);
if(replacement_pos < 0) break;
const head = out.slice(0, replacement_pos);
const meat = steps[step_key].result[key];
const meat = result[key];
const tail = out.slice(replacement_pos + search_replacement.length);
out = head + meat + tail;
@@ -160,7 +318,31 @@ async function generate_zip() {
console.log(out);
out_zip.file("thesis.typ", out);
return out;
}
async function generate_zip() {
let assets_zip = await fetch("template.zip");
let out_zip = await new JSZip().loadAsync(await assets_zip.blob());
let template = await out_zip.file(steps.thesis_type.result.type + ".typ").async("string");
template = generate_template_header() + template;
out_zip.forEach((path, _) => {
if(path.match(/^[a-z_]*\.typ$/)) {
out_zip.remove(path)
}
});
out_zip.file("thesis.typ", template);
if(!steps.assignment.result.include_pdf) {
out_zip.remove("assignment.pdf");
}
if(!steps.toolchain.result.include_makefile) {
out_zip.remove("Makefile");
}
return await out_zip.generateAsync({ type: "blob" });
}

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="styles.css">
<script type="text/javascript" src="lib/jszip.min.js"></script>
<script type="text/javascript" src="programmes.js"></script>
<script type="text/javascript" src="ui.js"></script>
<script type="text/javascript" src="utils.js"></script>
<script type="text/javascript" src="data.js"></script>

View File

@@ -134,6 +134,10 @@ button:hover, .button:hover {
background-color: white;
}
button.wide, .button.wide {
width: 100%;
}
button.narrow, .button.narrow {
padding: 4px 16px;
font-size: 16px;
@@ -184,6 +188,12 @@ button:disabled:hover, .button:disabled:hover {
justify-content: center;
}
.hint {
color: gray;
font-style: italic;
font-size: 16px;
}
.slide-animation {
position: relative;
@@ -292,6 +302,21 @@ input[type="text"]:disabled {
color: lightgray;
}
select {
flex-grow: 0;
padding: 8px 16px;
font-family: inherit;
font-size: 100%;
border: 1px var(--button-border-color) solid;
color: black;
background-color: var(--button-color);
border-radius: 8px;
box-sizing: border-box;
width: 100%;
}
.zmp-logo {
position: absolute;
bottom: 20px;

198
ui.js
View File

@@ -43,27 +43,64 @@ async function thesis_layout(content, result) {
}
}
async function author_info_layout(content, result) {
let author_name = new ElementBuilder("input")
.type("text")
.placeholder("Vaše jméno (vč. titulů), příp. jména všech autorů")
.on_input((e) => result.name = e.target.value)
.finish();
if(!Object.keys(pronounss).includes(result.pronouns)) {
async function language_layout(content, result) {
if(!Object.keys(languages).includes(result.lang)) {
document.querySelector("#next-button").disabled = true;
}
let pronouns_buttons = new ButtonListBuilder(Object.keys(pronounss))
let buttons = new ButtonListBuilder(Object.keys(languages))
.should_be_pressed((key) => result.lang == key)
.modify((b, key) => b.class("wide").text(languages[key].name))
.on_click((key) => {
result.lang = key;
document.querySelector("#next-button").disabled = false;
})
.finish();
for(const button of buttons) {
content.appendChild(button);
}
}
async function thesis_title_layout(content, result) {
content.appendChild(new TextInputBuilder(result, "cs")
.placeholder("Název práce (v češtině)")
.finish());
content.appendChild(new TextInputBuilder(result, "en")
.placeholder("Název práce (v angličtině)")
.finish());
content.appendChild(new ElementBuilder("div.vertical-spacer").finish());
content.appendChild(new ElementBuilder("div.hint")
.text("Pokud si zatím nejste jisti, můžete tato pole ponechat prázdná.")
.finish());
}
async function author_info_layout(content, result) {
let pronouns = pronouns_cs;
if(steps.language.result.lang === "en") {
pronouns = pronouns_en;
}
if(!Object.keys(pronouns).includes(result.pronouns)) {
document.querySelector("#next-button").disabled = true;
}
let pronouns_buttons = new ButtonListBuilder(Object.keys(pronouns))
.should_be_pressed((key) => result.pronouns == key)
.modify((b, key) => b.class("expand").text(pronounss[key].name))
.modify((b, key) => b.class("expand").text(pronouns[key].name))
.on_click((key) => {
result.pronouns = key;
document.querySelector("#next-button").disabled = false;
})
.finish();
content.appendChild(author_name);
content.appendChild(new TextInputBuilder(result, "name")
.placeholder("Vaše jméno (vč. titulů), příp. jména všech autorů")
.finish());
content.appendChild(new ElementBuilder("div.hint")
.text("Pokud si zatím nejste jisti, můžete ponechat toto pole prázdné.")
.finish());
content.appendChild(new ElementBuilder("div.vertical-spacer").finish());
content.appendChild(new ElementBuilder("div")
.text("Způsob sebeoslovení")
@@ -73,56 +110,109 @@ async function author_info_layout(content, result) {
.finish());
}
async function programme_layout(content, result) {
let faculty = steps.faculty.result.name;
let programmes_faculty = programmes[faculty];
let options = [
[[-1, "(žádný)"]],
programmes_faculty.map((x, idx) => [
idx,
"(" + x.title + ", " + x.form.slice(0, 4) + ".) " + x.cs_name
])
].flat().map(x => new ElementBuilder("option")
.value(x[0])
.text(x[1])
.finish());
content.appendChild(new ElementBuilder("select")
.append_all(options)
.on_change(e => result.idx = +e.target.value)
.value(result.faculty != faculty ? -1 : result.idx)
.finish());
result.faculty = faculty;
}
async function collaborators_layout(content, result) {
let supervisor_name = new ElementBuilder("input")
.type("text")
let supervisor_name = new TextInputBuilder(result, "supervisor_name")
.placeholder("Jméno vedoucího práce (vč. titulů), příp. vedoucích")
.on_input((e) => result.supervisor_name = e.target.value)
.finish();
let consultant_name = new ElementBuilder("input")
.type("text")
let consultant_name = new TextInputBuilder(result, "consultant_name")
.placeholder("Jméno konzultanta práce (vč. titulů), příp. konzultantů")
.on_input((e) => result.consultant_name = e.target.value)
.finish();
let has_supervisor = new ElementBuilder("input#has-supervisor")
.type("checkbox")
.checked(result.has_supervisor)
.on_click(() => update())
.finish();
let has_consultant = new ElementBuilder("input#has-consultant")
.type("checkbox")
.checked(result.has_consultant)
.on_click(() => update())
.finish();
function update() {
supervisor_name.disabled = !has_supervisor.checked;
consultant_name.disabled = !has_consultant.checked;
result.has_supervisor = has_supervisor.checked;
result.has_consultant = has_consultant.checked;
}
update();
content.appendChild(new ElementBuilder("div.horizontal-list")
.append(has_supervisor)
.append(new ElementBuilder("label")
.for("has-supervisor")
.text("Práce má vedoucího"))
content.appendChild(new BinaryInputBuilder(result, "has_supervisor")
.label("Práce má vedoucího")
.on_update(val => supervisor_name.disabled = !val)
.finish());
content.appendChild(supervisor_name);
content.appendChild(new ElementBuilder("div.vertical-spacer").finish());
content.appendChild(new ElementBuilder("div.horizontal-list")
.append(has_consultant)
.append(new ElementBuilder("label")
.for("has-consultant")
.text("Práce má konzultanta"))
content.appendChild(new BinaryInputBuilder(result, "has_consultant")
.label("Práce má konzultanta")
.on_update(val => consultant_name.disabled = !val)
.finish());
content.appendChild(consultant_name);
content.appendChild(new ElementBuilder("div.vertical-spacer").finish());
content.appendChild(new ElementBuilder("div.hint")
.text("Pokud si zatím nejste jisti jmény svých vedoucích, můžete tato pole ponechat prázdná.")
.finish());
}
async function assignment_layout(content, result) {
if(!Object.keys(assignment_formats).includes(result.format)) {
document.querySelector("#next-button").disabled = true;
}
let buttons = new ButtonListBuilder(Object.keys(assignment_formats))
.should_be_pressed((key) => result.format == key)
.modify((b, key) => b.class("wide").text(assignment_formats[key].name))
.on_click((key) => {
result.format = key;
document.querySelector("#next-button").disabled = false;
})
.finish();
for(const button of buttons) {
content.appendChild(button);
}
}
async function other_details_layout(content, result) {
content.appendChild(new BinaryInputBuilder(result, "has_keywords")
.label("Vložit klíčová slova")
.finish());
content.appendChild(new BinaryInputBuilder(result, "has_acknowledgement")
.label("Vložit poděkování")
.finish());
content.appendChild(new ElementBuilder("div.vertical-spacer").finish());
content.appendChild(new ElementBuilder("div.hint")
.text("Generátor pouze vygeneruje ukázky, jak by tato pole měla vypadat. Samotná klíčová slova či poděkování si poté upravíte dle svých potřeb.")
.finish());
}
async function toolchain_layout(content, result) {
if(!Object.keys(toolchains).includes(result.toolchain)) {
document.querySelector("#next-button").disabled = true;
}
let buttons = new ButtonListBuilder(Object.keys(toolchains))
.should_be_pressed((key) => result.toolchain == key)
.modify((b, key) => b.class("wide").text(toolchains[key].name))
.on_click((key) => {
result.toolchain = key;
document.querySelector("#next-button").disabled = false;
})
.finish();
for(const button of buttons) {
content.appendChild(button);
}
content.appendChild(new ElementBuilder("div.vertical-spacer").finish());
content.appendChild(new ElementBuilder("div.hint")
.text("Toto rozhodnutí pouze ovlivní, zda výsledná šablona bude obsahovat soubor Makefile pro jednodušší použití s příkazovou řádkou. Webový editor Typstu tento soubor nepotřebuje.")
.finish());
}
async function run() {
@@ -166,14 +256,20 @@ async function run() {
let steps_len = Object.keys(steps).length;
while(id < steps_len) {
let step = get_step(id);
if(step.condition && !step.condition()) {
if(animation > 0) id++;
if(animation < 0) id--;
continue;
}
back_button.style.display = (id != 0) ? "" : "none";
next_button.innerText = (id < steps_len - 1) ? "Další →" : "Dokončit";
next_button.disabled = false;
container.innerHTML = "";
let step = get_step(id);
let title = new ElementBuilder("h1")
.html(step.title)
.finish();

View File

@@ -61,6 +61,11 @@ class ElementBuilder {
return this;
}
on_change(handler) {
this.element.onchange = handler;
return this;
}
for(fr) {
this.element.htmlFor = fr;
return this;
@@ -91,6 +96,11 @@ class ElementBuilder {
return this;
}
value(value) {
this.element.value = value;
return this;
}
checked(checked) {
this.element.checked = checked;
return this;
@@ -181,6 +191,74 @@ class ButtonListBuilder {
}
}
class TextInputBuilder {
constructor(result, key) {
this.result = result;
this.key = key;
this.text = "";
}
placeholder(placeholder) {
this.text = placeholder;
return this;
}
finish() {
return new ElementBuilder("input")
.type("text")
.placeholder(this.text)
.value(this.result[this.key])
.on_input((e) => this.result[this.key] = e.target.value)
.finish();
}
}
class BinaryInputBuilder {
constructor(result, key) {
this.result = result;
this.key = key;
this.text = "";
this.update_handler = null;
}
label(label) {
this.text = label;
return this;
}
on_update(handler) {
this.update_handler = handler;
return this;
}
finish() {
let input = new ElementBuilder("input#" + this.key)
.type("checkbox")
.checked(this.result[this.key])
.on_click(e => {
console.log(e.target.checked);
this.result[this.key] = e.target.checked;
if(this.update_handler !== null) {
this.update_handler(e.target.checked);
}
})
.finish();
let bundle = new ElementBuilder("div.horizontal-list")
.append(input)
.append(new ElementBuilder("label")
.for(this.key)
.text(this.text))
.finish();
if(this.update_handler !== null) {
this.update_handler(this.result[this.key]);
}
return bundle;
}
}
async function delay_ms(ms) {
await new Promise((r) => setTimeout(r, ms));
}