Skip to content

Commit b23dcd8

Browse files
authored
User interface (#3)
* Basic GUI layout * Combobox * Input file frames * Finish creator front end * Finalize report creation * Proper error handling with anyhow * Basic merge UI * Error handling * Finalize CSV processing * Fixup compiler * Remove compile command from the GUI * Description * Authors * Progressbar and threading * Environment setup script for Windows (bash) * Expand license allowlist * Waiting GIF * LFS * Add logo * Change per review request * Code review fixup * Resolve code review requests * Code review comments
1 parent e78e4a3 commit b23dcd8

File tree

14 files changed

+577
-152
lines changed

14 files changed

+577
-152
lines changed

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
src/blob/ssfe.png filter=lfs diff=lfs merge=lfs -text

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ Cargo.lock
1515

1616
# Compiled latex code
1717
*.pdf
18+
.vscode/settings.json

Cargo.toml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
11
[package]
22
name = "cloggen"
33
version = "1.4.0"
4-
edition = "2021"
4+
edition = "2024"
55
license = "MIT"
6+
rust-version = "1.88"
7+
description = "Utility for automatic opinion generation for habilitation reports of the Student Council FE UL (ŠSFE)."
8+
authors = [
9+
"David Hožič"
10+
]
611

712
[dependencies]
8-
tectonic = {version = "0.15.0", features=["external-harfbuzz"]}
913
clap = { version = "4.5.20", features = ["derive"] }
1014
encoding_rs = "0.8.35"
1115
serde_json = "1.0.132"
1216
csv = "1.3.0"
1317
rand = "0.8.5"
1418
glob = "0.3.2"
19+
tectonic = { version = "0.15.0", features = ["external-harfbuzz"] }
20+
anyhow = "1.0.100"
21+
22+
# GUI dependencies
23+
egui = {version = "0.33.0", optional = true}
24+
eframe = {version = "0.33.0", optional = true}
25+
rfd = {version = "0.15.4", optional = true}
26+
open = {version = "5.3.2", optional = true}
27+
egui_extras = { version = "0.33.0", features = ["gif", "http"], optional = true}
28+
image = { version = "0.25.8", default-features = false, features = ["png"], optional = true}
29+
30+
[features]
31+
gui = ["dep:egui", "dep:eframe", "dep:rfd", "dep:open", "dep:egui_extras", "dep:image"]
32+
default = ["gui"]

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
# CLOGGEN
22
Generator študentskih mnenj (za habilitacijo).
33

4+
## Kloniranje repozitorija
5+
Za kloniranje repozitorija je predhodno potrebno namestiti [git-lfs](https://git-lfs.com/).
6+
7+
Ob naloženih zahtevah je mogoče repozitorij klonirati z ukazom `git clone`.
8+
Nato je potrebno zagnati `git lfs pull`.
9+
410
## Namestitev pisave (font)
511
Generiran dokument uporablja pisavo *Roboto*. V primeru, da pisava na sistemu ni nameščena, se dokument ne bo generiral.
612
Za namestitev uporabi datoteke v mapi ``mnenja-template/fonts/Roboto``. Namesti vse datoteke.
@@ -10,6 +16,8 @@ Za namestitev uporabi datoteke v mapi ``mnenja-template/fonts/Roboto``. Namesti
1016
Cloggen se zanaša na LaTeX prevajalnik imenovan [Tectonic](https://tectonic-typesetting.github.io/book/latest/index.html).
1117
Tectonic se zanaša na določene sistemske knjižnice, ki jih je potrebno predhodno namestiti.
1218

19+
Neodvisno od operacijskega sistema je predhodno potrebno nastaviti C++ standard na C++17 (`CXXFLAGS=-std=c++17`).
20+
1321
### Linux
1422
Prevajanje na Linuxu je enostavno. Namestiti je potrebno pakete preko upravljalnika paketov:
1523

about.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ accepted = [
77
"WTFPL",
88
"CC0-1.0",
99
"Artistic-2.0",
10-
"ISC"
10+
"ISC",
11+
"Zlib",
12+
"BSL-1.0",
13+
"OFL-1.1",
14+
"Ubuntu-font-1.0",
15+
"BSD-2-Clause"
1116
]
1217

1318
ignore-build-dependencies = true

env-windows.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Bash script for setting up the environmental variables.
2+
# Before running this, install vcpkg and the required packages (see README.md).
3+
# To setup the environment, source this file.
4+
export VCPKG_ROOT=$(realpath "../vcpkg/")
5+
export RUSTFLAGS='-Ctarget-feature=+crt-static'
6+
export VCPKGRS_TRIPLET='x64-windows-static-release'
7+
export TECTONIC_DEP_BACKEND='vcpkg'

src/blob/ssfe.png

Lines changed: 3 additions & 0 deletions
Loading

src/compiler.rs

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
1+
use tectonic::driver::{OutputFormat, ProcessingSessionBuilder};
2+
use tectonic::status::termcolor::TermcolorStatusBackend;
3+
use tectonic::config::PersistentConfig;
4+
use tectonic::status::ChatterLevel;
5+
6+
17
use std::fs::{read_to_string, File};
8+
use std::time::SystemTime;
29
use std::path::PathBuf;
3-
use tectonic as tec;
410
use std::io::Write;
511
use std::env;
612

713
use crate::with_parent_path;
814

915

1016
/// Modification of [`tectonic::latex_to_pdf`] which adds stdout print to the console.
11-
pub fn compile_latex(latex: impl AsRef<str>) -> Vec<u8> {
12-
let mut status = tec::status::termcolor::TermcolorStatusBackend::new(tec::status::ChatterLevel::Minimal);
13-
let config = tec::config::PersistentConfig::open(false).expect("could not open config");
14-
let bundle = config.default_bundle(false, &mut status).expect("could not get bundle");
17+
pub fn compile_latex(latex: impl AsRef<str>) -> std::io::Result<Vec<u8>> {
18+
let mut status = TermcolorStatusBackend::new(ChatterLevel::Normal);
19+
let config = PersistentConfig::open(false)?;
20+
let bundle = config.default_bundle(false, &mut status)?;
1521
let mut files = {
1622
let mut sess;
17-
let mut sb = tec::driver::ProcessingSessionBuilder::default();
18-
let format_cache_path = config.format_cache_path().expect("could not get format cache path");
23+
let mut sb = ProcessingSessionBuilder::default();
24+
let format_cache_path = config.format_cache_path()?;
1925
sb.bundle(bundle)
2026
.primary_input_buffer(latex.as_ref().as_bytes())
2127
.tex_input_name("texput.tex")
@@ -24,19 +30,21 @@ pub fn compile_latex(latex: impl AsRef<str>) -> Vec<u8> {
2430
.keep_logs(false)
2531
.keep_intermediates(false)
2632
.print_stdout(false)
27-
.output_format(tec::driver::OutputFormat::Pdf)
33+
.output_format(OutputFormat::Pdf)
34+
.build_date(SystemTime::now())
2835
.do_not_write_output_files();
29-
sess = sb.create(&mut status).unwrap();
30-
sess.run(&mut status).unwrap();
36+
sess = sb.create(&mut status)?;
37+
sess.run(&mut status)?;
3138
sess.into_file_data()
3239
};
33-
files.remove("texput.pdf").expect("compilation was successful but file data was not created").data
40+
Ok(files.remove("texput.pdf").expect("compilation was successful but file data was not created").data)
3441
}
3542

3643

37-
pub fn cmd_compile(path: &PathBuf) {
38-
let fdata = read_to_string(path).expect("unable to read file");
39-
let compiled = with_parent_path!(path, {compile_latex(fdata)});
40-
let mut file = File::create(path.display().to_string() + ".pdf").expect("unable to create output file");
41-
file.write_all(&compiled).expect("unable to write latex to file");
44+
pub fn cmd_compile(path: &PathBuf) -> std::io::Result<()> {
45+
let fdata = read_to_string(path)?;
46+
let compiled = with_parent_path!(path, {compile_latex(fdata)?});
47+
let mut file = File::create(path.display().to_string() + ".pdf")?;
48+
file.write_all(&compiled)?;
49+
Ok(())
4250
}

src/config.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//! Common configuration.
2+
/// Constants used for the Create command.
3+
#[allow(unused)]
4+
pub mod create {
5+
use crate::create::OutputFormat;
6+
7+
/// Default section inside the CSV file to parse. STUDIS CSV files can have multiple sections ---
8+
/// e.g., section about the subject, section about the teacher, etc.
9+
pub const SECTION_DEFAULT: &str = "Anketa o izvajalcu";
10+
11+
/// Default output format.
12+
pub const FORMAT_DEFAULT: OutputFormat = OutputFormat::Pdf;
13+
pub const FORMAT_DEFAULT_STR: &str = "pdf";
14+
}
15+
16+
/// Constants used for the Merge command.
17+
pub mod merge {
18+
/// Default section inside the CSV file to parse. STUDIS CSV files can have multiple sections ---
19+
/// e.g., section about the subject, section about the teacher, etc.
20+
pub const SECTION_DEFAULT: &str = super::create::SECTION_DEFAULT;
21+
/// The default output file of the merged CSV data.
22+
pub const OUTPUT_DEFAULT: &str = "merged.csv";
23+
}

src/create.rs

Lines changed: 48 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
/// Module related to the ``create`` command.
22
33
use rand::distributions::Uniform;
4-
use std::path::{Path, PathBuf};
5-
use std::io::{Read, Write};
6-
use serde_json as sj;
74
use rand::{thread_rng, Rng};
5+
6+
use anyhow::{Context, Result, anyhow};
7+
use serde_json as sj;
88
use clap::ValueEnum;
9+
10+
use std::path::{Path, PathBuf};
11+
use std::io::{Read, Write};
912
use std::fs::File;
1013
use std::time;
1114
use std::env;
@@ -30,10 +33,10 @@ pub fn command_create(
3033
studis_csv_filepath: &PathBuf,
3134
response_json_filepath: &PathBuf,
3235
tex_template_filepath: &PathBuf,
33-
section: &String,
36+
section: &str,
3437
format: &OutputFormat,
3538
output_filepath: &Option<PathBuf>
36-
) -> String {
39+
) -> Result<String> {
3740
const E_NOT_MAPPING: &str = "Not a JSON mapping";
3841

3942
const C_MEAN_CSV_KEY: &str = "Povprečje";
@@ -48,25 +51,25 @@ pub fn command_create(
4851
let mut file: File;
4952
let mut output_fdata: String = String::new();
5053
let mut output_parts = Vec::new();
51-
file = File::open(tex_template_filepath).expect(&format!("could not open tex file ({tex_template_filepath:?})"));
52-
file.read_to_string(&mut output_fdata).expect(&format!("Unable to read file {tex_template_filepath:?}"));
54+
file = File::open(tex_template_filepath).with_context(|| format!("could not open tex file ({tex_template_filepath:?})"))?;
55+
file.read_to_string(&mut output_fdata).with_context(|| format!("Unable to read file {tex_template_filepath:?}"))?;
5356

5457
if !output_fdata.contains(C_OUTPUT_LATEX_REPLACE_KEY) {
55-
panic!(
58+
return Err(anyhow!(
5659
"output file ({tex_template_filepath:?}) does not mark the location \
5760
of automatically-generated content (generated by this script). Mark it by writing \
5861
\"{C_OUTPUT_LATEX_REPLACE_KEY}\" somewhere in the file"
59-
);
62+
));
6063
}
6164

6265
// Process STUDIS CSV file.
63-
let fdata = fs::read_file_universal(studis_csv_filepath).expect("unable to read STUDIS CSV");
64-
let csvgrades = preproc::extract_section_columns(preproc::preprocess_candidate_csv(fdata), section);
66+
let fdata = fs::read_file_universal(studis_csv_filepath).with_context(|| "unable to read STUDIS CSV")?;
67+
let csvgrades = preproc::extract_section_columns(preproc::preprocess_candidate_csv(fdata), section)?;
6568

6669
// Process JSON file. This is the file containing responses for each category and each grade.
67-
file = File::open(response_json_filepath).expect(&format!("could not open responses file ({response_json_filepath:?})"));
68-
let json_map: sj::Map<_, _> = sj::from_reader(file).expect(E_NOT_MAPPING);
69-
let categories: &sj::Map<_, _> = &json_map[C_JSON_MAP_QUESTION_KEY].as_object().expect(E_NOT_MAPPING);
70+
file = File::open(response_json_filepath).with_context(|| format!("could not open responses file ({response_json_filepath:?})"))?;
71+
let json_map: sj::Map<_, _> = sj::from_reader(file).with_context(|| E_NOT_MAPPING)?;
72+
let categories: &sj::Map<_, _> = json_map[C_JSON_MAP_QUESTION_KEY].as_object().with_context(|| E_NOT_MAPPING)?;
7073
let mut idx: usize;
7174
let mut start_size: usize;
7275

@@ -80,21 +83,22 @@ pub fn command_create(
8083
// Iterate each category/question of the JSON responses file
8184
for (cat, grades_json) in categories {
8285
// Get index of the question matching JSON category
83-
idx = csvgrades.get(C_JSON_MAP_QUESTION_KEY).expect("CSV is missing questions key.")
84-
.iter().position(|x| x == cat).expect(&format!("CSV is missing category \"{cat}\""));
86+
idx = csvgrades.get(C_JSON_MAP_QUESTION_KEY).with_context(|| "CSV is missing questions key.")?
87+
.iter().position(|x| x == cat).with_context(|| format!("CSV is missing category \"{cat}\""))?;
8588

8689
// Read the String of the mean and std, then parse them to float
87-
smean = &csvgrades.get(C_MEAN_CSV_KEY).expect("CSV is missing the mean grade value key")[idx];
88-
mean = smean.parse().unwrap();
89-
sstd = &csvgrades.get(C_STD_CSV_KEY).expect("CSV is missing the std of grade key")[idx];
90-
// std = sstd.parse().unwrap();
90+
smean = &csvgrades.get(C_MEAN_CSV_KEY).with_context(|| "CSV is missing the mean grade value key")?[idx];
91+
mean = smean.parse().with_context(|| format!("failed to parse {smean} as a float"))?;
92+
sstd = &csvgrades.get(C_STD_CSV_KEY).with_context(|| "CSV is missing the std of grade key")?[idx];
9193

9294
// Obtain the mapping of min. grade => array of String responses
93-
let grades_json = grades_json.as_object().cloned().expect(E_NOT_MAPPING);
94-
let mut grades: Vec<(&String, f64)> = grades_json.keys().map(
95-
// Save grades in format (String (original), parsed float)
96-
|x| (x, x.parse().expect(&format!("grades must be floats (\"{x}\")")))
97-
).collect();
95+
let grades_json = grades_json.as_object().cloned().with_context(|| E_NOT_MAPPING)?;
96+
let mut grades = Vec::with_capacity(grades_json.len());
97+
// Try to parse each grade into a float. If any fails, return the error.
98+
for k in grades_json.keys() {
99+
let parsed: f64 = k.parse().with_context(|| format!("grades must be floats (\"{k}\")"))?;
100+
grades.push((k, parsed));
101+
}
98102

99103
// Sort the grades by the parsed value
100104
grades.sort_by(|(_, a), (_, b)| b.total_cmp(a));
@@ -110,26 +114,30 @@ pub fn command_create(
110114
let response_str;
111115
for (sgrade, grade) in grades.iter() {
112116
if (grade * 10000.0) as usize <= (mean * 10000.0) as usize { // Prevent influence of numeric error
113-
let v = grades_json.get(*sgrade).unwrap();
117+
let v = &grades_json[*sgrade]; // no need to check existence,because grades is generated from grades_json
114118

115119
// Query elapsed nanoseconds in order to improve randomness.
116120
epoch_ns = time::SystemTime::now()
117-
.duration_since(time::UNIX_EPOCH)
118-
.expect("SystemTime is before EPOCH!")
121+
.duration_since(time::UNIX_EPOCH)?
119122
.as_nanos();
120-
responses = v.as_array()
121-
.expect(&format!("value of Category->Grade->Value must be an array of strings. Found {v:?}"));
122123

123-
if responses.len() == 0 {
124-
panic!("there are no defined responses for grade {sgrade}, category {cat:?}");
124+
// Try to parse the array of possible responses under the given grade.
125+
responses = v.as_array().with_context(|| format!(
126+
"value of Category->Grade->Value must be an array of strings. Found {v:?}"
127+
))?;
128+
129+
if responses.is_empty() {
130+
return Err(anyhow!("there are no defined responses for grade {sgrade}, category {cat:?}"));
125131
}
126132

127133
response_idx = (
128134
(epoch_ns % responses.len() as u128
129135
+ rgn.sample(Uniform::new(0, responses.len())) as u128) % responses.len() as u128
130136
) as usize;
131137
response = &responses[response_idx];
132-
response_str = response.as_str().expect(&format!("responses must be strings ({response} is not)"));
138+
response_str = response.as_str().with_context(
139+
|| format!("responses must be strings ({response} is not)")
140+
)?;
133141
output_parts.push(
134142
response_str.replace(C_OUTPUT_LATEX_MEAN_KEY, smean)
135143
.replace(C_OUTPUT_LATEX_STD_KEY, sstd)
@@ -140,7 +148,7 @@ pub fn command_create(
140148

141149
// Check if loop was not break-ed;
142150
if start_size == output_parts.len() {
143-
panic!("could not find grade below mean ({mean}) for category \"{cat}\"");
151+
return Err(anyhow!("could not find grade below mean ({mean}) for category \"{cat}\""));
144152
}
145153
}
146154

@@ -172,43 +180,19 @@ pub fn command_create(
172180
output += ".tex";
173181
}
174182

175-
file = File::create(&output).expect("could not write output LaTex");
176-
file.write_all(output_fdata.as_bytes()).unwrap();
183+
file = File::create(&output).with_context(|| "could not write output LaTex")?;
184+
file.write_all(output_fdata.as_bytes())?;
177185
},
178186
OutputFormat::Pdf => {
179187
if !output.ends_with(".pdf") {
180188
output += ".pdf";
181189
}
182190

183-
let pdfdata = with_parent_path!(tex_template_filepath, {compiler::compile_latex(output_fdata)});
184-
file = File::create(&output).expect("could not create final PDF");
185-
file.write_all(&pdfdata).unwrap();
191+
let pdfdata = with_parent_path!(tex_template_filepath, {compiler::compile_latex(output_fdata)?});
192+
file = File::create(&output).with_context(|| "could not create final PDF")?;
193+
file.write_all(&pdfdata)?;
186194
}
187195
}
188196

189-
return output;
190-
}
191-
192-
193-
#[cfg(test)]
194-
mod tests {
195-
use std::path::PathBuf;
196-
use super::*;
197-
198-
const CSV: &str = "anketa.csv";
199-
const JSON: &str = "odzivi.json";
200-
const LATEX: &str = "mnenja-template/mnenje.tex";
201-
202-
#[test]
203-
fn test_create() {
204-
let path = command_create(
205-
&PathBuf::from(CSV),
206-
&PathBuf::from(JSON),
207-
&PathBuf::from(LATEX),
208-
&"Anketa o izvajalcu".to_string(),
209-
&OutputFormat::Pdf,
210-
&None
211-
);
212-
std::fs::remove_file(path).expect("ouput PDF file not created");
213-
}
197+
Ok(output)
214198
}

0 commit comments

Comments
 (0)