@@ -0,0 +1,342 @@
|
||||
// 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::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,
|
||||
} => {
|
||||
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(false),
|
||||
attempts: record.assessment.attempts.unwrap_or(1),
|
||||
scoring_policy: record
|
||||
.assessment
|
||||
.scoring_policy
|
||||
.unwrap_or(coursebank::assessment::ScoringPolicy::KeepHighest),
|
||||
};
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the `--variant` flags, defaulting to every document.
|
||||
///
|
||||
/// # 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::ALL.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,
|
||||
} => {
|
||||
let variants = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user