Files
coursebank/src/commands/export.rs
T
2026-09-19 20:06:41 -04:00

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)
}
}
}