// 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 { 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
= 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::>() .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 .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 .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> { 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> { 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 { 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 to \ see what it currently produces", path.display() ))); } yaml::write_text(&path, typst::CONFIG_TEMPLATE)?; println!("wrote {}", path.display()); Ok(Outcome::Ok) } } }