474 lines
17 KiB
Rust
474 lines
17 KiB
Rust
// SPDX-License-Identifier: Prosperity-3.0.0
|
|
// Copyright Scientific Computing Studio
|
|
// Source: https://git.scient.ing/education/coursebank
|
|
|
|
//! Turning an assembled assessment into deliverables.
|
|
//!
|
|
//! [`export`] writes the three output formats (a Canvas QTI package, rendered
|
|
//! Typst documents, and review Markdown). [`template`] inspects, dumps, and
|
|
//! configures the Typst templates those documents are injected into. Both share
|
|
//! [`pick_variants`], which resolves the `--variant` flags into a canonical list.
|
|
|
|
use coursebank::assessment::Form;
|
|
use coursebank::error::{Error, Result};
|
|
use coursebank::layout::Layout;
|
|
use coursebank::practice;
|
|
use coursebank::qti;
|
|
use coursebank::typst;
|
|
use coursebank::yaml;
|
|
|
|
use crate::cli::{Cli, ExportCommand, TemplateCommand};
|
|
use crate::commands::Outcome;
|
|
use crate::helpers::{load, load_record, markdown_export, pick_form};
|
|
|
|
/// `export`: build a QTI package, render Typst documents, or write Markdown.
|
|
pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
|
|
let catalog = load(cli)?;
|
|
let build = catalog.layout.build();
|
|
|
|
match sub {
|
|
ExportCommand::Qti {
|
|
id,
|
|
form,
|
|
out,
|
|
no_feedback,
|
|
attempts: _,
|
|
} => {
|
|
let record = load_record(&catalog, id)?;
|
|
let form = pick_form(&record, form)?;
|
|
let opts = qti::QtiOptions {
|
|
form: form.clone(),
|
|
include_feedback: !no_feedback,
|
|
shuffle_in_canvas: record.assessment.shuffle.unwrap_or(true),
|
|
// No per-assessment attempts value falls back to unlimited, the
|
|
// same default `QtiOptions::default()` carries. Keeping this in
|
|
// step with the struct default avoids a record without an
|
|
// `attempts:` silently becoming single-attempt here while the
|
|
// library considers the default to be unlimited.
|
|
attempts: record.assessment.attempts.unwrap_or(-1),
|
|
scoring_policy: record
|
|
.assessment
|
|
.scoring_policy
|
|
.unwrap_or(coursebank::assessment::ScoringPolicy::KeepHighest),
|
|
// The remaining fields drive `assessment_meta.xml` (quiz type,
|
|
// results visibility, correct-answer display, one-question-at-a-
|
|
// time, timing, publish state). This command exposes no flags for
|
|
// them yet, so take the library defaults: a formative graded quiz
|
|
// that lets students review responses and the correct answer, and
|
|
// imports unpublished.
|
|
..qti::QtiOptions::default()
|
|
};
|
|
let package = qti::build(&catalog, &record, &opts)?;
|
|
let path = out
|
|
.clone()
|
|
.unwrap_or_else(|| build.join(format!("{id}-{}.zip", form.id)));
|
|
package.write_zip(&path)?;
|
|
println!("wrote {}", path.display());
|
|
println!("Import in Canvas: Settings -> Import Course Content -> QTI .zip file");
|
|
Ok(Outcome::Ok)
|
|
}
|
|
ExportCommand::Typst {
|
|
id,
|
|
form,
|
|
out,
|
|
variant,
|
|
template,
|
|
json,
|
|
dry_run,
|
|
} => {
|
|
let record = load_record(&catalog, id)?;
|
|
let dir = out.clone().unwrap_or(build);
|
|
let variants = pick_variants(variant)?;
|
|
|
|
if template.is_some() && variants.len() > 1 {
|
|
return Err(Error::usage(
|
|
"--template applies to one document, but more than one --variant was \
|
|
requested; pass --variant exam (or key, or answer-sheet) alongside it"
|
|
.to_string(),
|
|
));
|
|
}
|
|
|
|
let forms: Vec<Form> = if form == "all" {
|
|
if record.forms.is_empty() {
|
|
vec![typst::Options::default().form]
|
|
} else {
|
|
record.forms.clone()
|
|
}
|
|
} else {
|
|
vec![pick_form(&record, form)?]
|
|
};
|
|
|
|
// Read once, outside both loops: the config describes the course, not
|
|
// the form, and re-reading it per form would let a mid-run edit make
|
|
// form A and form B disagree.
|
|
let config_file = typst::load_config(&catalog.layout)?;
|
|
let mut warned = false;
|
|
let mut used_embedded = false;
|
|
|
|
for f in &forms {
|
|
for variant in &variants {
|
|
let opts = typst::Options {
|
|
form: f.clone(),
|
|
variant: *variant,
|
|
template: template.clone(),
|
|
config: config_file.resolve(*variant),
|
|
};
|
|
|
|
let rendered = typst::render(&catalog, &record, &opts)?;
|
|
used_embedded |= rendered.origin == typst::Origin::Embedded;
|
|
|
|
for warning in &rendered.warnings {
|
|
eprintln!("warning: {warning}");
|
|
warned = true;
|
|
}
|
|
|
|
let stem = format!("{id}-{}{}", f.id, variant.suffix());
|
|
|
|
if *dry_run {
|
|
println!(
|
|
"{}: {} question(s) from {} via {}",
|
|
stem,
|
|
rendered.payload.questions.len(),
|
|
rendered.origin,
|
|
rendered
|
|
.slots
|
|
.iter()
|
|
.map(|s| s.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(" + ")
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let path = dir.join(format!("{stem}.typ"));
|
|
yaml::write_text(&path, &rendered.text)?;
|
|
println!("wrote {} (from {})", path.display(), rendered.origin);
|
|
|
|
if *json {
|
|
let json_path = dir.join(format!("{stem}.json"));
|
|
yaml::write_text(&json_path, &rendered.payload.to_json()?)?;
|
|
println!("wrote {}", json_path.display());
|
|
}
|
|
}
|
|
}
|
|
|
|
if *dry_run {
|
|
return Ok(Outcome::Ok);
|
|
}
|
|
|
|
if !cli.quiet {
|
|
println!("\nCompile with: pixi run -e docs typst compile <file>.typ");
|
|
if used_embedded {
|
|
println!(
|
|
"Some of these used a built-in template. To take over the layout:\n \
|
|
coursebank template dump"
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(if warned {
|
|
Outcome::Findings
|
|
} else {
|
|
Outcome::Ok
|
|
})
|
|
}
|
|
ExportCommand::Md { id, with_key, out } => {
|
|
let record = load_record(&catalog, id)?;
|
|
let markdown = markdown_export(&catalog, &record, *with_key)?;
|
|
let path = out
|
|
.clone()
|
|
.unwrap_or_else(|| build.join(format!("{id}.md")));
|
|
yaml::write_text(&path, &markdown)?;
|
|
println!("wrote {}", path.display());
|
|
Ok(Outcome::Ok)
|
|
}
|
|
ExportCommand::Practice {
|
|
id,
|
|
form,
|
|
variant,
|
|
out,
|
|
no_answer_space,
|
|
} => {
|
|
let record = load_record(&catalog, id)?;
|
|
let form = pick_form(&record, form)?;
|
|
let dir = out.clone().unwrap_or(build);
|
|
for v in pick_practice_variants(variant)? {
|
|
let opts = practice::Options {
|
|
form: form.clone(),
|
|
variant: v,
|
|
answer_space: !no_answer_space,
|
|
};
|
|
let text = practice::render(&catalog, &record, &opts)?;
|
|
let path = dir.join(format!("{id}-{}{}.qmd", form.id, v.suffix()));
|
|
yaml::write_text(&path, &text)?;
|
|
println!("wrote {}", path.display());
|
|
}
|
|
if !cli.quiet {
|
|
println!("\nRender with: quarto render <file>.qmd");
|
|
}
|
|
Ok(Outcome::Ok)
|
|
}
|
|
ExportCommand::Site {
|
|
id,
|
|
form,
|
|
out,
|
|
password,
|
|
assets,
|
|
} => {
|
|
{
|
|
use coursebank::site;
|
|
let record = load_record(&catalog, id)?;
|
|
let form = pick_form(&record, form)?;
|
|
let dir = out.clone().unwrap_or(build);
|
|
std::fs::create_dir_all(&dir)?;
|
|
|
|
let rendered = site::render(
|
|
&catalog,
|
|
&record,
|
|
site::Options {
|
|
form,
|
|
password: password.clone(),
|
|
},
|
|
)?;
|
|
|
|
let qmd = dir.join("_questions.qmd");
|
|
yaml::write_text(&qmd, &rendered.questions_qmd)?;
|
|
let json = dir.join(format!("{}-solutions.json", rendered.page));
|
|
yaml::write_text(&json, &rendered.solutions_json)?;
|
|
|
|
if let Some(asset_dir) = assets {
|
|
std::fs::create_dir_all(asset_dir)?;
|
|
for (name, body) in site::assets() {
|
|
let path = asset_dir.join(name);
|
|
yaml::write_text(&path, body)?;
|
|
if !cli.quiet {
|
|
println!("wrote {}", path.display());
|
|
}
|
|
}
|
|
}
|
|
|
|
// The password cannot be recovered from the files; always print it.
|
|
println!("password for {}: {}", rendered.page, rendered.password);
|
|
if !cli.quiet {
|
|
println!("wrote {}", qmd.display());
|
|
println!("wrote {}", json.display());
|
|
println!("\nInclude in the page with: {{{{< include _questions.qmd >}}}}");
|
|
}
|
|
Ok(Outcome::Ok)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolves the `--variant` flags for `export practice`, defaulting to both.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `names` - the raw flag values, possibly empty.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The documents to write, deduplicated and in canonical order (worksheet first).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`Error::Usage`] naming the valid tokens.
|
|
fn pick_practice_variants(names: &[String]) -> Result<Vec<practice::Variant>> {
|
|
if names.is_empty() {
|
|
return Ok(practice::Variant::ALL.to_vec());
|
|
}
|
|
let mut wanted = Vec::new();
|
|
for name in names {
|
|
let variant = practice::Variant::parse(name)?;
|
|
if !wanted.contains(&variant) {
|
|
wanted.push(variant);
|
|
}
|
|
}
|
|
Ok(practice::Variant::ALL
|
|
.into_iter()
|
|
.filter(|v| wanted.contains(v))
|
|
.collect())
|
|
}
|
|
|
|
/// Resolves the `--variant` flags, defaulting to the exam set.
|
|
///
|
|
/// The default is [`typst::Variant::EXAM`] rather than every variant: a
|
|
/// diagnostic is built from responses, not from an assessment record, so
|
|
/// `export typst` has nothing to build one out of.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `names` - the raw flag values, possibly empty.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The variants, deduplicated and in canonical order so that
|
|
/// `--variant key --variant exam` still writes the paper first.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`Error::Usage`] naming the valid tokens.
|
|
fn pick_variants(names: &[String]) -> Result<Vec<typst::Variant>> {
|
|
if names.is_empty() {
|
|
return Ok(typst::Variant::EXAM.to_vec());
|
|
}
|
|
let mut wanted = Vec::new();
|
|
for name in names {
|
|
let variant = typst::Variant::parse(name)?;
|
|
if !wanted.contains(&variant) {
|
|
wanted.push(variant);
|
|
}
|
|
}
|
|
// Canonical order, not the order they were typed.
|
|
Ok(typst::Variant::ALL
|
|
.into_iter()
|
|
.filter(|v| wanted.contains(v))
|
|
.collect())
|
|
}
|
|
|
|
/// `template`: list the template lookup, dump the built-ins to edit, or write and
|
|
/// inspect the render configuration.
|
|
pub(crate) fn template(cli: &Cli, sub: &TemplateCommand) -> Result<Outcome> {
|
|
let layout = Layout::new(&cli.course);
|
|
|
|
match sub {
|
|
TemplateCommand::List { assessment } => {
|
|
let id = assessment.as_deref();
|
|
println!("Templates are looked up in this order, first match wins:\n");
|
|
|
|
for variant in typst::Variant::ALL {
|
|
println!("{}", variant.as_str());
|
|
let mut resolved = false;
|
|
for path in typst::template::candidates(&layout, variant, id) {
|
|
let present = path.is_file();
|
|
let mark = if present && !resolved {
|
|
resolved = true;
|
|
"->"
|
|
} else {
|
|
" "
|
|
};
|
|
let state = if present { "" } else { " (absent)" };
|
|
println!(" {mark} {}{state}", path.display());
|
|
}
|
|
let mark = if resolved { " " } else { "->" };
|
|
println!(" {mark} built-in");
|
|
|
|
// Reporting the slots requires parsing, and a template with broken
|
|
// markers should be named here rather than at export time.
|
|
match typst::template::load(&layout, variant, id, None) {
|
|
Ok(template) => {
|
|
let slots: Vec<&str> =
|
|
template.slots().iter().map(|s| s.as_str()).collect();
|
|
if slots.is_empty() {
|
|
println!(" slots: none — this template injects nothing");
|
|
} else {
|
|
println!(" slots: {}", slots.join(", "));
|
|
}
|
|
}
|
|
Err(e) => println!(" unusable: {e}"),
|
|
}
|
|
println!();
|
|
}
|
|
|
|
let config = typst::config_path(&layout);
|
|
if config.is_file() {
|
|
println!("Config: {}", config.display());
|
|
} else {
|
|
println!(
|
|
"Config: none ({} is absent, so built-in defaults apply)",
|
|
config.display()
|
|
);
|
|
}
|
|
Ok(Outcome::Ok)
|
|
}
|
|
|
|
TemplateCommand::Dump {
|
|
variant,
|
|
out,
|
|
force,
|
|
stdout,
|
|
} => {
|
|
// `export typst` defaults to the exam set, because a report is not
|
|
// built from an assessment record. Dumping is the opposite case: with
|
|
// no `--variant` it should hand over every template there is,
|
|
// including the two reports.
|
|
let variants = if variant.is_empty() {
|
|
typst::Variant::ALL.to_vec()
|
|
} else {
|
|
pick_variants(variant)?
|
|
};
|
|
|
|
if *stdout {
|
|
for (index, v) in variants.iter().enumerate() {
|
|
if index > 0 {
|
|
println!();
|
|
}
|
|
if variants.len() > 1 {
|
|
println!("// ── {} ──", v.template_file());
|
|
}
|
|
print!("{}", typst::template::embedded(*v));
|
|
}
|
|
return Ok(Outcome::Ok);
|
|
}
|
|
|
|
let dir = out.clone().unwrap_or_else(|| layout.templates());
|
|
let (written, skipped) = typst::template::dump(&dir, &variants, *force)?;
|
|
|
|
for path in &written {
|
|
println!("wrote {}", path.display());
|
|
}
|
|
for path in &skipped {
|
|
println!(
|
|
"kept {} (already exists; --force to overwrite)",
|
|
path.display()
|
|
);
|
|
}
|
|
|
|
if !written.is_empty() && !cli.quiet {
|
|
println!(
|
|
"\nThese are yours to edit. Only the marked regions are replaced on \
|
|
export,\nso restyle freely:\n typst watch {}",
|
|
dir.join(typst::Variant::Exam.template_file()).display()
|
|
);
|
|
}
|
|
// Skipped files are worth an exit code: a script that expected to
|
|
// refresh them did not.
|
|
Ok(if skipped.is_empty() {
|
|
Outcome::Ok
|
|
} else {
|
|
Outcome::Findings
|
|
})
|
|
}
|
|
|
|
TemplateCommand::Config {
|
|
resolved,
|
|
out,
|
|
force,
|
|
} => {
|
|
if let Some(name) = resolved {
|
|
let variant = typst::Variant::parse(name)?;
|
|
let config_file = typst::load_config(&layout)?;
|
|
let config = config_file.resolve(variant);
|
|
println!(
|
|
"# Resolved configuration for `{}`, after every layer.",
|
|
variant.as_str()
|
|
);
|
|
print!("{}", config.to_yaml()?);
|
|
return Ok(Outcome::Ok);
|
|
}
|
|
|
|
let path = out.clone().unwrap_or_else(|| typst::config_path(&layout));
|
|
if path.exists() && !force {
|
|
return Err(Error::usage(format!(
|
|
"{} already exists; pass --force to overwrite it, or --resolved <variant> to \
|
|
see what it currently produces",
|
|
path.display()
|
|
)));
|
|
}
|
|
yaml::write_text(&path, typst::CONFIG_TEMPLATE)?;
|
|
println!("wrote {}", path.display());
|
|
Ok(Outcome::Ok)
|
|
}
|
|
}
|
|
}
|