feat: write quarto assignment with encryption
This commit is contained in:
+8
-7
@@ -17,10 +17,6 @@ path = "src/main.rs"
|
|||||||
name = "coursebank"
|
name = "coursebank"
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["parquet"]
|
|
||||||
parquet = ["dep:parquet", "dep:arrow-array", "dep:arrow-schema"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
csv = "1"
|
csv = "1"
|
||||||
@@ -29,9 +25,14 @@ serde_json = "1"
|
|||||||
serde_yaml_ng = "0.10"
|
serde_yaml_ng = "0.10"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|
||||||
arrow-array = { version = "55", optional = true }
|
arrow-array = { version = "55"}
|
||||||
arrow-schema = { version = "55", optional = true }
|
arrow-schema = { version = "55"}
|
||||||
parquet = { version = "55", optional = true }
|
parquet = { version = "55"}
|
||||||
|
aes-gcm = { version = "0.10"}
|
||||||
|
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"]}
|
||||||
|
sha2 = { version = "0.10"}
|
||||||
|
base64 = { version = "0.22"}
|
||||||
|
getrandom = { version = "0.2"}
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# An assignment on the web
|
||||||
|
|
||||||
|
This follows a single homework from an assembled record to a published Quarto page whose solutions stay locked until a student enters a password.
|
||||||
|
It assumes a course directory with approved items and an assembled assessment; if you do not have one, [`setup`](crate::guide::setup) and [`first_exam`](crate::guide::first_exam) build both.
|
||||||
|
|
||||||
|
The site export is the third off-Canvas path, alongside the printed exam in [`typst_export`](crate::guide::typst_export) and the plain-text worksheet from `export practice`.
|
||||||
|
The difference is where the solutions go: printed on a key you keep, or encrypted into a bundle that ships with the page and unlocks in the browser.
|
||||||
|
|
||||||
|
## Assemble the homework
|
||||||
|
|
||||||
|
Assemble it the same way you assemble an exam, with the platform set to the website rather than paper or Canvas:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ coursebank assemble a1.1 \
|
||||||
|
--title "Homework 1" \
|
||||||
|
--kind homework --platform other \
|
||||||
|
--levels 2=1,3=1 --lectures L1.1 --seed 20260210
|
||||||
|
```
|
||||||
|
|
||||||
|
What lands in `assessments/a1.1.yaml` is a record of the draw.
|
||||||
|
The id `a1.1` is the one thing to choose deliberately here: it becomes the bundle's file name and the name the page's gate points at, so keep it URL-safe and stable.
|
||||||
|
|
||||||
|
A single unshuffled form is fine for homework.
|
||||||
|
To hand different students different option orders, add `--forms 2` and export each form separately; the printed choices and the letters the solutions refer to move together, because both come from the form's recorded seed.
|
||||||
|
|
||||||
|
## Export for the web
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ coursebank export site a1.1 --out build/a1.1 --assets build/static
|
||||||
|
password for a1.1: k7m4-9p2q-r8tx-3wn6
|
||||||
|
wrote build/a1.1/_questions.qmd
|
||||||
|
wrote build/a1.1/a1.1-solutions.json
|
||||||
|
wrote build/static/questions.css
|
||||||
|
wrote build/static/solutions.js
|
||||||
|
|
||||||
|
Include in the page with: {{< include _questions.qmd >}}
|
||||||
|
```
|
||||||
|
|
||||||
|
That is four files and a password.
|
||||||
|
|
||||||
|
`_questions.qmd` is the partial you include from the page.
|
||||||
|
`a1.1-solutions.json` is the encrypted bundle, named for the assessment so several assignments can share one site.
|
||||||
|
The two files under `build/static` style the questions and perform the unlock; they install once for the whole site, not once per page, so `--assets` is something you run the first time and drop afterward.
|
||||||
|
|
||||||
|
The password is printed once and written nowhere.
|
||||||
|
Record it now.
|
||||||
|
It cannot be recovered from the files, which is the point: the bundle is useless without it, so losing it means re-exporting rather than reading it back.
|
||||||
|
|
||||||
|
## What the partial holds
|
||||||
|
|
||||||
|
The questions are Quarto fenced divs, with an empty, hidden slot where each solution will land:
|
||||||
|
|
||||||
|
```text
|
||||||
|
::: {.solutions-gate data-bundle="a1.1-solutions.json"}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: {.q #q-enthalpy-qp-001}
|
||||||
|
:::: {.q-head}
|
||||||
|
[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points}
|
||||||
|
::::
|
||||||
|
|
||||||
|
:::: {.q-stem}
|
||||||
|
A reaction is run in an open flask, so the system stays at the constant
|
||||||
|
pressure of the room. The heat the reaction exchanges with its surroundings
|
||||||
|
equals the change in which quantity?
|
||||||
|
::::
|
||||||
|
|
||||||
|
:::: {.q-choices}
|
||||||
|
1. Enthalpy, $\Delta H$
|
||||||
|
2. Internal energy, $\Delta U$
|
||||||
|
3. Gibbs free energy, $\Delta G$
|
||||||
|
4. Entropy, $\Delta S$
|
||||||
|
::::
|
||||||
|
|
||||||
|
:::: {.qsol data-solution-for="q-enthalpy-qp-001" hidden="true"}
|
||||||
|
::::
|
||||||
|
:::
|
||||||
|
```
|
||||||
|
|
||||||
|
The choices are printed without letters, and the stylesheet draws the A, B, C, D from their position.
|
||||||
|
There is nothing in this file to leak: no `correct` flag, no solution text, no rationale.
|
||||||
|
The `.qsol` slot is empty until the browser fills it, and the id in `data-solution-for` is the key the bundle looks up.
|
||||||
|
|
||||||
|
Math is written in `$ … $` and passed through untouched, so MathJax typesets it in the page.
|
||||||
|
|
||||||
|
## What the bundle holds
|
||||||
|
|
||||||
|
Every solution is rendered to HTML, then encrypted:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"page": "a1.1",
|
||||||
|
"kdf": { "name": "PBKDF2", "hash": "SHA-256", "iterations": 250000, "salt": "jqbE6xIB..." },
|
||||||
|
"cipher": "AES-GCM",
|
||||||
|
"items": {
|
||||||
|
"q-enthalpy-qp-001": { "iv": "8jzuxY...", "ct": "gmzNSlhioU...(+tag)" },
|
||||||
|
"q-enthalpy-derive-001": { "iv": "1BddCd...", "ct": "sC8czL547/...(+tag)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The password derives an AES-256 key through PBKDF2-HMAC-SHA256 at 250,000 iterations over a random salt.
|
||||||
|
Each solution is encrypted under its own random IV, with the authentication tag appended to the ciphertext.
|
||||||
|
The plaintext HTML never leaves your machine.
|
||||||
|
Items are listed in the order the questions appear, not sorted, so the reader's browser can decrypt the first one to check the password before touching the rest.
|
||||||
|
|
||||||
|
## Wire it into the site
|
||||||
|
|
||||||
|
Install the two assets once in `_quarto.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
format:
|
||||||
|
html:
|
||||||
|
css:
|
||||||
|
- static/questions.css
|
||||||
|
include-after-body:
|
||||||
|
- static/solutions.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Put `_questions.qmd` and `a1.1-solutions.json` in the page's own directory, and include the partial from the page:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: "Homework 1"
|
||||||
|
---
|
||||||
|
|
||||||
|
{{< include _questions.qmd >}}
|
||||||
|
```
|
||||||
|
|
||||||
|
The gate resolves `data-bundle` relative to the page URL, so keeping the bundle beside the page is enough.
|
||||||
|
If your build prunes files it does not see linked, add `resources: ["*-solutions.json"]` to the page front matter so the bundle ships with the render.
|
||||||
|
|
||||||
|
Render with `quarto render`.
|
||||||
|
A reader who opens the page sees the questions and a locked panel; typing the password decrypts the solutions in place, with the math typeset.
|
||||||
|
|
||||||
|
## Hand out the password, and rotate it
|
||||||
|
|
||||||
|
Give the password through a channel students already have, such as the course LMS, rather than the site itself.
|
||||||
|
|
||||||
|
Be clear-eyed about what the lock does.
|
||||||
|
It keeps solutions off a public page until someone has the password.
|
||||||
|
It does not make them secret in a strong sense: the whole bundle is downloaded, so anyone with the password, or anyone they share it with, can decrypt every item, and the ciphertext is open to an offline guessing attack.
|
||||||
|
Eighty bits of password entropy and a quarter-million PBKDF2 iterations make guessing slow, but the right mental model is a lock on a take-home worksheet, not a grading system of record.
|
||||||
|
|
||||||
|
Rotate the password after the due date by re-running the export, which mints a fresh one and a freshly encrypted bundle:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ coursebank export site a1.1 --out build/a1.1
|
||||||
|
password for a1.1: 2h9k-w4rq-8mnp-x6tv
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
To set a password yourself instead of generating one, pass `--password`.
|
||||||
|
Use that only when you have a reason to, such as re-encrypting an unchanged page with a password you already circulated.
|
||||||
|
|
||||||
|
## Doing this from Rust
|
||||||
|
|
||||||
|
The CLI is a thin wrapper over `coursebank::site`, which is compiled only with the `site` feature.
|
||||||
|
The example below needs `--features site` to build, so it is not run as a doctest:
|
||||||
|
|
||||||
|
```rust,ignore
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use coursebank::assessment::{AssessmentFile, Form};
|
||||||
|
use coursebank::site::{self, Options};
|
||||||
|
use coursebank::Catalog;
|
||||||
|
|
||||||
|
fn main() -> coursebank::Result<()> {
|
||||||
|
let catalog = Catalog::load(Path::new("."))?;
|
||||||
|
let path = catalog.layout.assessments().join("a1.1.yaml");
|
||||||
|
let record = AssessmentFile::load(&path)?;
|
||||||
|
|
||||||
|
// An unshuffled form A; declare a form with a seed to shuffle.
|
||||||
|
let form = Form { id: "A".into(), seed: 0, shuffle_items: false, shuffle_options: false };
|
||||||
|
|
||||||
|
// `password: None` generates a fresh one; pass `Some(_)` to set your own.
|
||||||
|
let rendered = site::render(&catalog, &record, Options { form, password: None })?;
|
||||||
|
|
||||||
|
std::fs::write("build/a1.1/_questions.qmd", &rendered.questions_qmd)?;
|
||||||
|
std::fs::write(
|
||||||
|
format!("build/a1.1/{}-solutions.json", rendered.page),
|
||||||
|
&rendered.solutions_json,
|
||||||
|
)?;
|
||||||
|
// The password is the one thing not on disk; print it now.
|
||||||
|
println!("password for {}: {}", rendered.page, rendered.password);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`site::assets()` returns the two browser files as name and contents, if you would rather write them from your own code than pass `--assets`.
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
questions.css — worksheet items + gated solutions
|
||||||
|
Sits beside editor-notes.css: same left-border-accent language, same
|
||||||
|
small-caps auto-headers, same .quarto-dark dark-mode hook.
|
||||||
|
|
||||||
|
Worksheet content is authored as Quarto MARKDOWN inside fenced divs, so
|
||||||
|
inline math is $ … $ and paragraphs/lists render normally. Choice letters
|
||||||
|
(A, B, C, D) are drawn by a CSS counter, so a choice is just a list item.
|
||||||
|
|
||||||
|
Semantic color coding (information, not decoration):
|
||||||
|
indigo = a solution / answer region
|
||||||
|
green = the correct choice / accepted answer
|
||||||
|
red = a named misconception
|
||||||
|
==========================================================================*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--q-ink: #1f2328;
|
||||||
|
--q-muted: #5a5a5a;
|
||||||
|
--q-line: #e4e4e4;
|
||||||
|
--q-card-bg: #ffffff;
|
||||||
|
|
||||||
|
--sol-accent: #4b4f9a; /* indigo: "here be answers" */
|
||||||
|
--sol-bg: #f5f5fb;
|
||||||
|
--sol-rule: #dedcef;
|
||||||
|
|
||||||
|
--ok-ink: #2f6249; /* correct / accepted (matches editorial green) */
|
||||||
|
--ok-bg: #e7f2ec;
|
||||||
|
--mis-ink: #a13d2e; /* misconception (matches editorial red) */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Question card ---- */
|
||||||
|
.q {
|
||||||
|
border: 1px solid var(--q-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--q-card-bg);
|
||||||
|
padding: 1.1rem 1.3rem 1.25rem;
|
||||||
|
margin: 1.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header. Authored as [Question 1]{.q-num} [..]{.q-kind} [..]{.q-points}; */
|
||||||
|
.q-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
padding-bottom: 0.55rem;
|
||||||
|
border-bottom: 1px solid var(--q-line);
|
||||||
|
}
|
||||||
|
.q-head > p { display: contents; margin: 0; }
|
||||||
|
.q-num { font-weight: 700; letter-spacing: 0.01em; }
|
||||||
|
.q-kind {
|
||||||
|
font-variant: small-caps; letter-spacing: 0.06em;
|
||||||
|
font-size: 0.78rem; color: var(--q-muted);
|
||||||
|
}
|
||||||
|
.q-points {
|
||||||
|
margin-left: auto; font-size: 0.8rem; color: var(--q-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-stem { margin: 0 0 0.9rem; }
|
||||||
|
.q-stem > p:first-child { margin-top: 0; }
|
||||||
|
.q-stem > p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* ---- Multiple choice (a plain ordered list; letters via counter) ---- */
|
||||||
|
.q-choices > ol {
|
||||||
|
list-style: none; margin: 0; padding: 0;
|
||||||
|
display: grid; gap: 0.5rem;
|
||||||
|
counter-reset: choice;
|
||||||
|
}
|
||||||
|
.q-choices > ol > li {
|
||||||
|
position: relative;
|
||||||
|
padding: 0.55rem 0.7rem 0.55rem 3rem; /* room for the badge on the left */
|
||||||
|
border: 1px solid var(--q-line);
|
||||||
|
border-radius: 5px;
|
||||||
|
counter-increment: choice;
|
||||||
|
}
|
||||||
|
.q-choices > ol > li::before {
|
||||||
|
content: counter(choice, upper-alpha); /* A, B, C, D … */
|
||||||
|
position: absolute;
|
||||||
|
left: 0.55rem; top: 0.5rem;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 1.9rem; height: 1.9rem;
|
||||||
|
border: 1px solid var(--sol-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-weight: 700; font-size: 0.9rem;
|
||||||
|
color: var(--sol-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Free-response writing space (prints with room to write) ----- */
|
||||||
|
.q-response {
|
||||||
|
min-height: 6.5rem;
|
||||||
|
border: 1px dashed var(--q-line);
|
||||||
|
border-radius: 5px;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
to bottom, transparent, transparent 1.55rem,
|
||||||
|
var(--q-line) 1.55rem, var(--q-line) calc(1.55rem + 1px));
|
||||||
|
background-position: 0 0.9rem;
|
||||||
|
}
|
||||||
|
.q-response::before {
|
||||||
|
content: "Your answer";
|
||||||
|
display: block;
|
||||||
|
font-variant: small-caps; letter-spacing: 0.06em;
|
||||||
|
font-size: 0.72rem; color: var(--q-muted);
|
||||||
|
padding: 0.3rem 0.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Solution slot (filled by solutions.js on unlock) ---- */
|
||||||
|
.qsol {
|
||||||
|
border-left: 3px solid var(--sol-accent);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
background: var(--sol-bg);
|
||||||
|
padding: 0.9rem 1.15rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.95rem; line-height: 1.55;
|
||||||
|
}
|
||||||
|
.qsol::before {
|
||||||
|
content: "Solution";
|
||||||
|
display: block;
|
||||||
|
font-variant: small-caps; letter-spacing: 0.06em; font-weight: 600;
|
||||||
|
color: var(--sol-accent);
|
||||||
|
padding-bottom: 0.35rem; margin-bottom: 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--sol-rule);
|
||||||
|
}
|
||||||
|
.qsol > p:first-of-type { margin-top: 0; }
|
||||||
|
.qsol > *:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
@keyframes sol-in { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; } }
|
||||||
|
.qsol.is-unlocked { animation: sol-in 180ms ease-out; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .qsol.is-unlocked { animation: none; } }
|
||||||
|
|
||||||
|
/* pieces inside a solution */
|
||||||
|
.sol-answer { font-size: 1.02rem; }
|
||||||
|
.sol-model {
|
||||||
|
border-left: 2px solid var(--ok-ink);
|
||||||
|
background: var(--ok-bg);
|
||||||
|
padding: 0.55rem 0.8rem; border-radius: 0 4px 4px 0; margin: 0.7rem 0;
|
||||||
|
}
|
||||||
|
.sol-model > p:first-child { margin-top: 0; }
|
||||||
|
.sol-model > p:last-child { margin-bottom: 0; }
|
||||||
|
.sol-explain { margin: 0.7rem 0; }
|
||||||
|
.sol-feedback-title {
|
||||||
|
font-variant: small-caps; letter-spacing: 0.05em; font-weight: 600;
|
||||||
|
color: var(--q-muted); margin: 0.9rem 0 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* per-distractor feedback: badge in col 1, BOTH text spans in col 2 ---- */
|
||||||
|
.sol-feedback { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.6rem; }
|
||||||
|
.sol-feedback > li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.9rem 1fr; /* badge | body */
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.sol-feedback .opt {
|
||||||
|
display: grid; place-items: center; width: 1.9rem; height: 1.9rem;
|
||||||
|
border-radius: 50%; font-weight: 700; font-size: 0.8rem;
|
||||||
|
color: var(--mis-ink); border: 1px solid var(--mis-ink);
|
||||||
|
}
|
||||||
|
.sol-feedback .opt-body { /* the single col-2 cell; fills 1fr */
|
||||||
|
display: grid; gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.sol-feedback .opt-mis { color: var(--mis-ink); font-style: italic; }
|
||||||
|
.sol-feedback .opt-why { color: var(--q-ink); }
|
||||||
|
|
||||||
|
/* rubric table */
|
||||||
|
.sol-rubric { width: 100%; border-collapse: collapse; margin: 0.7rem 0; font-size: 0.92rem; }
|
||||||
|
.sol-rubric caption {
|
||||||
|
text-align: left; font-variant: small-caps; letter-spacing: 0.05em;
|
||||||
|
font-weight: 600; color: var(--q-muted); padding-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.sol-rubric th, .sol-rubric td {
|
||||||
|
border-top: 1px solid var(--sol-rule); padding: 0.4rem 0.55rem; text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.sol-rubric th:first-child, .sol-rubric td:first-child {
|
||||||
|
width: 2.5rem; text-align: center; font-weight: 700; color: var(--ok-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sol-accepted { margin: 0.7rem 0; }
|
||||||
|
.sol-ref { margin-top: 0.7rem; font-size: 0.85rem; color: var(--q-muted); }
|
||||||
|
|
||||||
|
/* badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-block; font-variant: small-caps; letter-spacing: 0.05em;
|
||||||
|
font-size: 0.72rem; font-weight: 700; padding: 0.05em 0.5em;
|
||||||
|
border-radius: 999px; margin-right: 0.35em;
|
||||||
|
background: var(--sol-rule); color: var(--sol-accent);
|
||||||
|
}
|
||||||
|
.badge-correct { background: var(--ok-bg); color: var(--ok-ink); }
|
||||||
|
|
||||||
|
/* ---- The password gate --- */
|
||||||
|
.solutions-gate { margin: 1.4rem 0; }
|
||||||
|
.gate-inner {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--sol-rule); border-left: 3px solid var(--sol-accent);
|
||||||
|
border-radius: 0 5px 5px 0; background: var(--sol-bg);
|
||||||
|
}
|
||||||
|
.gate-lock {
|
||||||
|
width: 0.8rem; height: 0.7rem; border: 2px solid var(--sol-accent);
|
||||||
|
border-radius: 2px; position: relative; flex: none;
|
||||||
|
}
|
||||||
|
.gate-lock::before {
|
||||||
|
content: ""; position: absolute; left: 50%; top: -0.42rem; transform: translateX(-50%);
|
||||||
|
width: 0.5rem; height: 0.42rem; border: 2px solid var(--sol-accent);
|
||||||
|
border-bottom: none; border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
.gate-lock.is-open::before { left: 20%; }
|
||||||
|
.gate-label { font-variant: small-caps; letter-spacing: 0.05em; font-weight: 600; color: var(--sol-accent); }
|
||||||
|
.gate-input {
|
||||||
|
flex: 1 1 12rem; min-width: 9rem;
|
||||||
|
padding: 0.4rem 0.6rem; border: 1px solid var(--sol-rule);
|
||||||
|
border-radius: 4px; background: var(--q-card-bg); color: var(--q-ink);
|
||||||
|
}
|
||||||
|
.gate-btn {
|
||||||
|
padding: 0.42rem 1rem; border: 1px solid var(--sol-accent); border-radius: 4px;
|
||||||
|
background: var(--sol-accent); color: #fff; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.gate-btn:hover { filter: brightness(1.08); }
|
||||||
|
.gate-btn:disabled { opacity: 0.6; cursor: progress; }
|
||||||
|
.gate-btn-ghost { background: transparent; color: var(--sol-accent); }
|
||||||
|
.gate-status { flex-basis: 100%; margin: 0; font-size: 0.85rem; color: var(--q-muted); }
|
||||||
|
.solutions-gate.is-error .gate-input { border-color: var(--mis-ink); }
|
||||||
|
.solutions-gate.is-error .gate-status { color: var(--mis-ink); }
|
||||||
|
.gate-input:focus-visible, .gate-btn:focus-visible { outline: 2px solid var(--sol-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.solutions-gate { display: none; }
|
||||||
|
.qsol[hidden] { display: none; }
|
||||||
|
.q { break-inside: avoid; border-color: #bbb; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================ Dark mode ================================= */
|
||||||
|
.quarto-dark {
|
||||||
|
--q-ink: #dfe2e7;
|
||||||
|
--q-muted: #a7adb6;
|
||||||
|
--q-line: #333a44;
|
||||||
|
--q-card-bg: #1b1f26;
|
||||||
|
|
||||||
|
--sol-accent: #9aa0e6;
|
||||||
|
--sol-bg: #20222e;
|
||||||
|
--sol-rule: #343755;
|
||||||
|
|
||||||
|
--ok-ink: #9dd3b4;
|
||||||
|
--ok-bg: #1b241f;
|
||||||
|
--mis-ink: #e0a498;
|
||||||
|
}
|
||||||
|
.quarto-dark .gate-btn { color: #14161c; }
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* solutions.js — per-page, password-gated solutions for the course site.
|
||||||
|
*
|
||||||
|
* How a page opts in:
|
||||||
|
* 1. Put one gate element somewhere on the page:
|
||||||
|
* <div class="solutions-gate" data-bundle="a1.1-solutions.json"></div>
|
||||||
|
* `data-bundle` is resolved relative to the page URL, so each page points
|
||||||
|
* at its own bundle and therefore has its own password. Nothing else is
|
||||||
|
* shared between pages.
|
||||||
|
* 2. For every gated item, leave an empty, hidden slot where the solution
|
||||||
|
* should appear:
|
||||||
|
* <div class="solution" data-solution-for="q-enthalpy-qp-001" hidden></div>
|
||||||
|
* The id in data-solution-for must match a key in the bundle's `items`.
|
||||||
|
*
|
||||||
|
* What happens on unlock:
|
||||||
|
* The typed password is run through PBKDF2 (same params as the bundle's kdf
|
||||||
|
* block) to derive an AES-256-GCM key. The first item is decrypted as a
|
||||||
|
* probe: because GCM authenticates, a wrong password throws, and we report
|
||||||
|
* "wrong password" without revealing anything. On success every slot is
|
||||||
|
* filled, un-hidden, and MathJax re-typesets the injected math.
|
||||||
|
*
|
||||||
|
* The ciphertext ships in the page, so this stops a student
|
||||||
|
* from reading answers in "View source" or the Network tab, and a wrong
|
||||||
|
* password reveals nothing. Anyone who has the password can decrypt, and the
|
||||||
|
* bundle can be brute-forced offline against a weak password. Use a real,
|
||||||
|
* non-guessable per-page password, and rotate it if a key deadline has passed.
|
||||||
|
* ==========================================================================*/
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const b64ToBytes = (s) =>
|
||||||
|
Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||||
|
|
||||||
|
async function deriveKey(password, kdf) {
|
||||||
|
const base = await crypto.subtle.importKey(
|
||||||
|
"raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]);
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{ name: "PBKDF2", salt: b64ToBytes(kdf.salt),
|
||||||
|
iterations: kdf.iterations, hash: kdf.hash },
|
||||||
|
base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptItem(key, item) {
|
||||||
|
const pt = await crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-GCM", iv: b64ToBytes(item.iv) }, key, b64ToBytes(item.ct));
|
||||||
|
return new TextDecoder().decode(pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeset(nodes) {
|
||||||
|
if (window.MathJax && typeof window.MathJax.typesetPromise === "function") {
|
||||||
|
window.MathJax.typesetPromise(nodes).catch(() => { /* leave as-is */ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGate(gate) {
|
||||||
|
gate.classList.add("is-locked");
|
||||||
|
gate.innerHTML = `
|
||||||
|
<div class="gate-inner">
|
||||||
|
<span class="gate-lock" aria-hidden="true"></span>
|
||||||
|
<label class="gate-label" for="gate-pw">Solutions are locked</label>
|
||||||
|
<input class="gate-input" id="gate-pw" type="password"
|
||||||
|
autocomplete="off" spellcheck="false"
|
||||||
|
placeholder="Enter the page password" />
|
||||||
|
<button class="gate-btn" type="button">Unlock</button>
|
||||||
|
<p class="gate-status" role="status" aria-live="polite"></p>
|
||||||
|
</div>`;
|
||||||
|
return {
|
||||||
|
input: gate.querySelector(".gate-input"),
|
||||||
|
button: gate.querySelector(".gate-btn"),
|
||||||
|
status: gate.querySelector(".gate-status"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlock(gate, ui) {
|
||||||
|
const url = gate.getAttribute("data-bundle");
|
||||||
|
const pw = ui.input.value;
|
||||||
|
if (!pw) { ui.input.focus(); return; }
|
||||||
|
|
||||||
|
gate.classList.remove("is-error");
|
||||||
|
ui.button.disabled = true;
|
||||||
|
ui.status.textContent = "Checking\u2026";
|
||||||
|
|
||||||
|
let bundle;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(`bundle ${res.status}`);
|
||||||
|
bundle = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
ui.button.disabled = false;
|
||||||
|
ui.status.textContent = "Could not load the solutions file for this page.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key;
|
||||||
|
try {
|
||||||
|
key = await deriveKey(pw, bundle.kdf);
|
||||||
|
// Probe with the first item so a wrong password fails before we touch DOM.
|
||||||
|
const firstId = Object.keys(bundle.items)[0];
|
||||||
|
await decryptItem(key, bundle.items[firstId]);
|
||||||
|
} catch (e) {
|
||||||
|
gate.classList.add("is-error");
|
||||||
|
ui.button.disabled = false;
|
||||||
|
ui.status.textContent = "That password didn\u2019t work. Try again.";
|
||||||
|
ui.input.select();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filled = [];
|
||||||
|
for (const slot of document.querySelectorAll("[data-solution-for]")) {
|
||||||
|
const id = slot.getAttribute("data-solution-for");
|
||||||
|
const item = bundle.items[id];
|
||||||
|
if (!item) continue;
|
||||||
|
try {
|
||||||
|
slot.innerHTML = await decryptItem(key, item);
|
||||||
|
slot.hidden = false;
|
||||||
|
slot.classList.add("is-unlocked");
|
||||||
|
filled.push(slot);
|
||||||
|
} catch (e) { /* skip an item that fails; others still unlock */ }
|
||||||
|
}
|
||||||
|
typeset(filled);
|
||||||
|
|
||||||
|
gate.classList.remove("is-locked");
|
||||||
|
gate.classList.add("is-unlocked");
|
||||||
|
gate.innerHTML = `
|
||||||
|
<div class="gate-inner">
|
||||||
|
<span class="gate-lock is-open" aria-hidden="true"></span>
|
||||||
|
<span class="gate-label">Solutions unlocked</span>
|
||||||
|
<button class="gate-btn gate-btn-ghost" type="button">Hide again</button>
|
||||||
|
</div>`;
|
||||||
|
gate.querySelector(".gate-btn").addEventListener("click", () => {
|
||||||
|
for (const s of filled) { s.hidden = true; s.classList.remove("is-unlocked"); }
|
||||||
|
buildAndWire(gate); // relock the UI; content stays in memory only
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAndWire(gate) {
|
||||||
|
const ui = buildGate(gate);
|
||||||
|
const go = () => unlock(gate, ui);
|
||||||
|
ui.button.addEventListener("click", go);
|
||||||
|
ui.input.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const gate = document.querySelector(".solutions-gate[data-bundle]");
|
||||||
|
if (!gate) return;
|
||||||
|
if (!window.crypto || !crypto.subtle) {
|
||||||
|
gate.textContent =
|
||||||
|
"This browser can\u2019t decrypt solutions (no Web Crypto over http/file).";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buildAndWire(gate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
+24
@@ -435,6 +435,30 @@ pub(crate) enum ExportCommand {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_answer_space: bool,
|
no_answer_space: bool,
|
||||||
},
|
},
|
||||||
|
/// Write a Quarto questions partial and an encrypted, password-gated
|
||||||
|
/// solutions bundle for a course website.
|
||||||
|
///
|
||||||
|
/// Writes `_questions.qmd` and `<id>-solutions.json` into the output directory,
|
||||||
|
/// and prints a fresh password that the files do not store.
|
||||||
|
Site {
|
||||||
|
/// Assessment id.
|
||||||
|
id: String,
|
||||||
|
/// Which form's option order to print.
|
||||||
|
#[arg(long, default_value = "A")]
|
||||||
|
form: String,
|
||||||
|
/// Output directory; defaults to build/. Point it at the page's own
|
||||||
|
/// directory so the browser fetches the bundle beside the page.
|
||||||
|
#[arg(long)]
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
/// Encrypt with this password instead of a generated one. Use only to
|
||||||
|
/// re-encrypt a page with a known password.
|
||||||
|
#[arg(long)]
|
||||||
|
password: Option<String>,
|
||||||
|
/// Also write questions.css and solutions.js into this directory, e.g.
|
||||||
|
/// the site's static assets folder. They install once, not per page.
|
||||||
|
#[arg(long, value_name = "DIR")]
|
||||||
|
assets: Option<PathBuf>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
|
|||||||
@@ -195,6 +195,55 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
|
|||||||
}
|
}
|
||||||
Ok(Outcome::Ok)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,5 +29,4 @@ pub mod canvas;
|
|||||||
pub mod gradescope;
|
pub mod gradescope;
|
||||||
pub mod responses;
|
pub mod responses;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
#[cfg(feature = "parquet")]
|
|
||||||
pub mod store_parquet;
|
pub mod store_parquet;
|
||||||
|
|||||||
+2
-31
@@ -49,21 +49,14 @@ impl Format {
|
|||||||
///
|
///
|
||||||
/// Parquet when the `parquet` feature is on, CSV otherwise.
|
/// Parquet when the `parquet` feature is on, CSV otherwise.
|
||||||
pub fn preferred() -> Format {
|
pub fn preferred() -> Format {
|
||||||
#[cfg(feature = "parquet")]
|
Format::Parquet
|
||||||
{
|
|
||||||
Format::Parquet
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "parquet"))]
|
|
||||||
{
|
|
||||||
Format::Csv
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this format can be written by the current build.
|
/// Whether this format can be written by the current build.
|
||||||
pub fn is_available(self) -> bool {
|
pub fn is_available(self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Format::Csv => true,
|
Format::Csv => true,
|
||||||
Format::Parquet => cfg!(feature = "parquet"),
|
Format::Parquet => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,21 +379,10 @@ fn read_csv(path: &Path) -> Result<ResponseSet> {
|
|||||||
///
|
///
|
||||||
/// * `path` - the destination.
|
/// * `path` - the destination.
|
||||||
/// * `rows` - the rows.
|
/// * `rows` - the rows.
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns [`Error::FeatureDisabled`] when the feature is off.
|
|
||||||
#[cfg(feature = "parquet")]
|
|
||||||
fn write_parquet(path: &Path, rows: &[FlatResponse]) -> Result<()> {
|
fn write_parquet(path: &Path, rows: &[FlatResponse]) -> Result<()> {
|
||||||
crate::store_parquet::write(path, rows)
|
crate::store_parquet::write(path, rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stub for builds without Parquet support.
|
|
||||||
#[cfg(not(feature = "parquet"))]
|
|
||||||
fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> {
|
|
||||||
Err(Error::FeatureDisabled("Parquet", "parquet"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads flat responses from Parquet.
|
/// Reads flat responses from Parquet.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
@@ -414,7 +396,6 @@ fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> {
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`Error::FeatureDisabled`] when the feature is off.
|
/// Returns [`Error::FeatureDisabled`] when the feature is off.
|
||||||
#[cfg(feature = "parquet")]
|
|
||||||
fn read_parquet(path: &Path) -> Result<ResponseSet> {
|
fn read_parquet(path: &Path) -> Result<ResponseSet> {
|
||||||
let rows = crate::store_parquet::read(path)?;
|
let rows = crate::store_parquet::read(path)?;
|
||||||
let mut set = ResponseSet::new();
|
let mut set = ResponseSet::new();
|
||||||
@@ -422,16 +403,6 @@ fn read_parquet(path: &Path) -> Result<ResponseSet> {
|
|||||||
Ok(set)
|
Ok(set)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stub for builds without Parquet support.
|
|
||||||
#[cfg(not(feature = "parquet"))]
|
|
||||||
fn read_parquet(path: &Path) -> Result<ResponseSet> {
|
|
||||||
Err(Error::Other(format!(
|
|
||||||
"{} is a Parquet file, but this build has Parquet support compiled out; \
|
|
||||||
rebuild with `--features parquet`, or re-ingest with `--format csv`",
|
|
||||||
path.display()
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Makes an administration id usable as a file name.
|
/// Makes an administration id usable as a file name.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
|
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
|
||||||
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
|
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
|
||||||
//! | [`practice`] | Quarto Markdown | a worksheet and a solutions document, off Canvas |
|
//! | [`practice`] | Quarto Markdown | a worksheet and a solutions document, off Canvas |
|
||||||
|
//! | [`site`] | a Quarto partial and an encrypted bundle | a course page with password-gated solutions |
|
||||||
//! | [`report`] | Markdown and HTML | students, and yourself |
|
//! | [`report`] | Markdown and HTML | students, and yourself |
|
||||||
//! | [`lecture`] | Markdown | the reading list on the course website |
|
//! | [`lecture`] | Markdown | the reading list on the course website |
|
||||||
//!
|
//!
|
||||||
@@ -26,4 +27,5 @@ pub mod lecture;
|
|||||||
pub mod practice;
|
pub mod practice;
|
||||||
pub mod qti;
|
pub mod qti;
|
||||||
pub mod report;
|
pub mod report;
|
||||||
|
pub mod site;
|
||||||
pub mod typst;
|
pub mod typst;
|
||||||
|
|||||||
@@ -47,9 +47,7 @@ const IMSMD_NS: &str = "http://www.imsglobal.org/xsd/imsmd_v1p2";
|
|||||||
const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \
|
const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \
|
||||||
http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd";
|
http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd";
|
||||||
|
|
||||||
// ---
|
|
||||||
// A very small XML tree
|
// A very small XML tree
|
||||||
// ---
|
|
||||||
|
|
||||||
/// One XML element.
|
/// One XML element.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -155,9 +153,7 @@ fn escape_attr(s: &str) -> String {
|
|||||||
escape_text(s).replace('"', """)
|
escape_text(s).replace('"', """)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---
|
|
||||||
// Package construction
|
// Package construction
|
||||||
// ---
|
|
||||||
|
|
||||||
/// Options for a QTI export.
|
/// Options for a QTI export.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@@ -0,0 +1,976 @@
|
|||||||
|
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||||
|
// Copyright Scientific Computing Studio
|
||||||
|
// Source: https://git.scient.ing/education/coursebank
|
||||||
|
|
||||||
|
//! Rendering an assessment for a Quarto course website, with solutions gated
|
||||||
|
//! behind a per-page password.
|
||||||
|
//!
|
||||||
|
//! This is the path that publishes to the web rather than to Canvas or a printed
|
||||||
|
//! exam. It produces three things for one assessment:
|
||||||
|
//!
|
||||||
|
//! 1. A `_questions.qmd` partial: the questions as Quarto fenced divs, with an
|
||||||
|
//! empty, hidden `.qsol` slot per item. A page includes it with
|
||||||
|
//! `{{< include _questions.qmd >}}`. It carries no answers.
|
||||||
|
//! 2. A `<id>-solutions.json` bundle: every solution rendered to an HTML fragment,
|
||||||
|
//! then encrypted. The ciphertext ships in the page, but the plaintext never
|
||||||
|
//! does, so a student cannot read answers from the source or the network tab.
|
||||||
|
//! 3. A password: a fresh, random, per-bundle password that decrypts the bundle.
|
||||||
|
//! It is printed for the instructor and stored nowhere, so it cannot be
|
||||||
|
//! recovered from the files. Hand it out, and rotate it after a due date.
|
||||||
|
//!
|
||||||
|
//! The browser side is [`assets`]: `questions.css` styles the questions and the
|
||||||
|
//! unlocked solutions, and `solutions.js` derives the key from the typed password,
|
||||||
|
//! decrypts, and injects each fragment. The crypto here matches that script byte
|
||||||
|
//! for byte: PBKDF2-HMAC-SHA256 at 250,000 iterations derives a 256-bit key, and
|
||||||
|
//! AES-256-GCM encrypts each fragment under a fresh 96-bit IV with the 128-bit tag
|
||||||
|
//! appended to the ciphertext. Get any parameter wrong and a correct password
|
||||||
|
//! would fail to authenticate.
|
||||||
|
//!
|
||||||
|
//! Two rules carry over from the other exporters. A question paper never contains
|
||||||
|
//! the answer: the `.qsol` slot is empty in the qmd and the answer lives only in
|
||||||
|
//! the encrypted bundle. And option order comes from the form's seed, so the
|
||||||
|
//! printed letters in the questions and the letters the solution refers to are the
|
||||||
|
//! same order.
|
||||||
|
|
||||||
|
use crate::assessment::{AssessmentFile, Form, Placement};
|
||||||
|
use crate::catalog::Catalog;
|
||||||
|
use crate::course::{CourseFile, Reference};
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::item::{Choice, Citation, Item, Solution};
|
||||||
|
use crate::markup;
|
||||||
|
use crate::select;
|
||||||
|
use crate::taxonomy::Format;
|
||||||
|
|
||||||
|
use aes_gcm::Aes256Gcm;
|
||||||
|
use aes_gcm::aead::generic_array::GenericArray;
|
||||||
|
use aes_gcm::aead::{Aead, KeyInit};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
|
use serde::ser::SerializeMap;
|
||||||
|
use serde::{Serialize, Serializer};
|
||||||
|
|
||||||
|
/// PBKDF2 iteration count. Must match `solutions.js` and `make_solutions.py`.
|
||||||
|
const ITERATIONS: u32 = 250_000;
|
||||||
|
|
||||||
|
/// Crockford base32 without the ambiguous `i`, `l`, `o`, `u`. Thirty-two symbols
|
||||||
|
/// means five bits each, so sixteen of them carry eighty bits of entropy.
|
||||||
|
const PW_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
|
||||||
|
|
||||||
|
/// The bundled browser assets, installed once per site (not per page).
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Pairs of file name and verbatim contents: the stylesheet and the unlock
|
||||||
|
/// script. Write them into the site's static directory and wire them in
|
||||||
|
/// `_quarto.yml`.
|
||||||
|
pub fn assets() -> [(&'static str, &'static str); 2] {
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"questions.css",
|
||||||
|
include_str!("../assets/site/questions.css"),
|
||||||
|
),
|
||||||
|
("solutions.js", include_str!("../assets/site/solutions.js")),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How to render one assessment for the site.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Options {
|
||||||
|
/// The form whose option order to print.
|
||||||
|
pub form: Form,
|
||||||
|
/// A password to encrypt with. `None` generates a fresh random one, which is
|
||||||
|
/// the intended path; pass `Some` only to re-encrypt a bundle with a known
|
||||||
|
/// password.
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Options {
|
||||||
|
/// Builds options for a form, generating the password.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `form` - the form whose option order to print.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Options that will generate a fresh password at render time.
|
||||||
|
pub fn new(form: Form) -> Options {
|
||||||
|
Options {
|
||||||
|
form,
|
||||||
|
password: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything one render produces, ready to write.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Rendered {
|
||||||
|
/// The assessment id, used for the bundle file name and the gate's
|
||||||
|
/// `data-bundle` attribute.
|
||||||
|
pub page: String,
|
||||||
|
/// The `_questions.qmd` partial.
|
||||||
|
pub questions_qmd: String,
|
||||||
|
/// The `<id>-solutions.json` bundle, serialized and newline-terminated.
|
||||||
|
pub solutions_json: String,
|
||||||
|
/// The password that decrypts the bundle. Print it; it is stored nowhere.
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders an assessment into the questions partial and the encrypted bundle.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `catalog` - the loaded course, for items, objectives, and references.
|
||||||
|
/// * `record` - the assembled assessment.
|
||||||
|
/// * `opts` - the form and an optional password.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The partial, the serialized bundle, and the password.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::Unresolved`] when a placement names an item the catalog does
|
||||||
|
/// not hold, and [`Error::Other`] on a CSPRNG, encryption, or serialization
|
||||||
|
/// failure.
|
||||||
|
pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: Options) -> Result<Rendered> {
|
||||||
|
let page = record.assessment.id.clone();
|
||||||
|
let questions_qmd = questions_qmd(catalog, record, &opts.form, &page)?;
|
||||||
|
let fragments = solution_fragments(catalog, record, &opts.form)?;
|
||||||
|
let password = match opts.password {
|
||||||
|
Some(p) => p,
|
||||||
|
None => gen_password()?,
|
||||||
|
};
|
||||||
|
let bundle = build_bundle(&page, &password, &fragments)?;
|
||||||
|
let json = serde_json::to_string_pretty(&bundle)
|
||||||
|
.map_err(|e| Error::other(format!("could not serialize the solutions bundle: {e}")))?;
|
||||||
|
Ok(Rendered {
|
||||||
|
page,
|
||||||
|
questions_qmd,
|
||||||
|
solutions_json: format!("{json}\n"),
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the `_questions.qmd` partial.
|
||||||
|
fn questions_qmd(
|
||||||
|
catalog: &Catalog,
|
||||||
|
record: &AssessmentFile,
|
||||||
|
form: &Form,
|
||||||
|
page: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str(
|
||||||
|
"<!-- _questions.qmd — generated by `coursebank export site`. No YAML front\n\
|
||||||
|
\x20 matter, no title, no prose. Include from the page with:\n\
|
||||||
|
\x20 {{< include _questions.qmd >}} -->\n\n",
|
||||||
|
);
|
||||||
|
out.push_str(&format!(
|
||||||
|
"::: {{.solutions-gate data-bundle=\"{page}-solutions.json\"}}\n:::\n\n"
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut number = 0usize;
|
||||||
|
let default_points = catalog.course.policy.points_per_item;
|
||||||
|
let layout = select::layout(record, form);
|
||||||
|
for placement in layout.iter().filter(|p| !p.dropped) {
|
||||||
|
let item = &catalog.require(&placement.item)?.item;
|
||||||
|
number += 1;
|
||||||
|
out.push_str(&question_block(
|
||||||
|
number,
|
||||||
|
placement,
|
||||||
|
item,
|
||||||
|
form,
|
||||||
|
default_points,
|
||||||
|
));
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave exactly one trailing newline.
|
||||||
|
while out.ends_with("\n\n") {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `.q` block: head, stem, choices or a writing box, then the empty slot.
|
||||||
|
fn question_block(
|
||||||
|
number: usize,
|
||||||
|
placement: &Placement,
|
||||||
|
item: &Item,
|
||||||
|
form: &Form,
|
||||||
|
default_points: f64,
|
||||||
|
) -> String {
|
||||||
|
let id = &item.id;
|
||||||
|
let points = placement
|
||||||
|
.points
|
||||||
|
.unwrap_or_else(|| item.points(default_points));
|
||||||
|
let unit = if (points - 1.0).abs() < f64::EPSILON {
|
||||||
|
"point"
|
||||||
|
} else {
|
||||||
|
"points"
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut b = String::new();
|
||||||
|
b.push_str(&format!("::: {{.q #{id}}}\n"));
|
||||||
|
b.push_str(":::: {.q-head}\n");
|
||||||
|
b.push_str(&format!(
|
||||||
|
"[Question {number}]{{.q-num}} [{}]{{.q-kind}} [{} {unit}]{{.q-points}}\n",
|
||||||
|
kind_label(item.format),
|
||||||
|
trim_number(points),
|
||||||
|
));
|
||||||
|
b.push_str("::::\n\n");
|
||||||
|
|
||||||
|
b.push_str(":::: {.q-stem}\n");
|
||||||
|
b.push_str(&markup::to_markdown(&item.stem));
|
||||||
|
b.push_str("\n::::\n\n");
|
||||||
|
|
||||||
|
if item.has_options() {
|
||||||
|
b.push_str(":::: {.q-choices}\n");
|
||||||
|
let order = select::option_order(form, &placement.item, item.options.len());
|
||||||
|
for (position, &source) in order.iter().enumerate() {
|
||||||
|
b.push_str(&format!(
|
||||||
|
"{}. {}\n",
|
||||||
|
position + 1,
|
||||||
|
markup::to_markdown(&item.options[source].text)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
b.push_str("::::\n\n");
|
||||||
|
} else {
|
||||||
|
b.push_str(":::: {.q-response aria-hidden=\"true\"}\n::::\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
b.push_str(&format!(
|
||||||
|
":::: {{.qsol data-solution-for=\"{id}\" hidden=\"true\"}}\n::::\n"
|
||||||
|
));
|
||||||
|
b.push_str(":::\n");
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The human label for a format, shown in the question head.
|
||||||
|
fn kind_label(format: Format) -> &'static str {
|
||||||
|
match format {
|
||||||
|
Format::SingleBestAnswer => "Single best answer",
|
||||||
|
Format::MultipleResponse => "Multiple response",
|
||||||
|
Format::TrueFalse => "True or false",
|
||||||
|
Format::OpenResponse => "Open response",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the solution fragments ---
|
||||||
|
|
||||||
|
/// Renders each item's solution to an HTML fragment, in printed order.
|
||||||
|
///
|
||||||
|
/// An item with nothing to show (an open-response question whose solution is
|
||||||
|
/// empty) is left out, so its slot simply never unlocks.
|
||||||
|
fn solution_fragments(
|
||||||
|
catalog: &Catalog,
|
||||||
|
record: &AssessmentFile,
|
||||||
|
form: &Form,
|
||||||
|
) -> Result<Vec<(String, String)>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let layout = select::layout(record, form);
|
||||||
|
for placement in layout.iter().filter(|p| !p.dropped) {
|
||||||
|
let item = &catalog.require(&placement.item)?.item;
|
||||||
|
if let Some(html) = fragment(&catalog.course, placement, item, form) {
|
||||||
|
out.push((item.id.clone(), html));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fragment for one item, or `None` when there is nothing to show.
|
||||||
|
fn fragment(
|
||||||
|
course: &CourseFile,
|
||||||
|
placement: &Placement,
|
||||||
|
item: &Item,
|
||||||
|
form: &Form,
|
||||||
|
) -> Option<String> {
|
||||||
|
if item.has_options() {
|
||||||
|
Some(choice_fragment(course, placement, item, form))
|
||||||
|
} else {
|
||||||
|
open_fragment(course, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single-best-answer or multiple-response fragment: the key, the model answer,
|
||||||
|
/// the explanation, then per-distractor feedback.
|
||||||
|
fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form: &Form) -> String {
|
||||||
|
let order = select::option_order(form, &placement.item, item.options.len());
|
||||||
|
let printed: Vec<(usize, &Choice)> = order
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(position, &source)| (position, &item.options[source]))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
|
||||||
|
let keyed: Vec<String> = printed
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, c)| c.correct)
|
||||||
|
.map(|(position, c)| {
|
||||||
|
format!(
|
||||||
|
"<b>{}</b> — {}",
|
||||||
|
letter(*position),
|
||||||
|
inline_html(&c.text)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.push_str(
|
||||||
|
"<p class=\"sol-answer\"><span class=\"badge badge-correct\">Correct answer</span> ",
|
||||||
|
);
|
||||||
|
out.push_str(&keyed.join("; "));
|
||||||
|
out.push_str("</p>\n");
|
||||||
|
|
||||||
|
if let Some(solution) = item.solution.as_ref() {
|
||||||
|
if let Some(model) = &solution.model_answer {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<div class=\"sol-model\">{}</div>\n",
|
||||||
|
block_html(model)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(explanation) = &solution.explanation {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<p class=\"sol-explain\">{}</p>\n",
|
||||||
|
inline_html(explanation)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let distractors: Vec<(usize, &Choice)> = printed
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|(_, c)| !c.correct)
|
||||||
|
.collect();
|
||||||
|
if distractors
|
||||||
|
.iter()
|
||||||
|
.any(|(_, c)| c.misconception.is_some() || why_wrong(c).is_some())
|
||||||
|
{
|
||||||
|
out.push_str("<p class=\"sol-feedback-title\">Why the other options miss</p>\n");
|
||||||
|
out.push_str("<ul class=\"sol-feedback\">\n");
|
||||||
|
for (position, c) in &distractors {
|
||||||
|
if c.misconception.is_none() && why_wrong(c).is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push_str(&format!(
|
||||||
|
" <li><span class=\"opt\">{}</span>\n",
|
||||||
|
letter(*position)
|
||||||
|
));
|
||||||
|
out.push_str(" <div class=\"opt-body\">\n");
|
||||||
|
if let Some(mis) = &c.misconception {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" <span class=\"opt-mis\">{}</span>\n",
|
||||||
|
inline_html(mis)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(why) = why_wrong(c) {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" <span class=\"opt-why\">{}</span>\n",
|
||||||
|
inline_html(why)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str(" </div></li>\n");
|
||||||
|
}
|
||||||
|
out.push_str("</ul>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
push_reference(&mut out, course, item.solution.as_ref());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An open-response fragment: the model answer, the rubric, accepted variants.
|
||||||
|
fn open_fragment(course: &CourseFile, item: &Item) -> Option<String> {
|
||||||
|
let solution = item.solution.as_ref().filter(|s| !s.is_empty())?;
|
||||||
|
let mut out = String::new();
|
||||||
|
|
||||||
|
if let Some(model) = &solution.model_answer {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<div class=\"sol-model\">{}</div>\n",
|
||||||
|
block_html(model)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !solution.rubric.is_empty() {
|
||||||
|
let caption = match solution.rubric_points() {
|
||||||
|
Some(total) => {
|
||||||
|
let unit = if (total - 1.0).abs() < f64::EPSILON {
|
||||||
|
"point"
|
||||||
|
} else {
|
||||||
|
"points"
|
||||||
|
};
|
||||||
|
format!("Rubric — {} {unit}", trim_number(total))
|
||||||
|
}
|
||||||
|
None => "Rubric".to_string(),
|
||||||
|
};
|
||||||
|
out.push_str("<table class=\"sol-rubric\">\n");
|
||||||
|
out.push_str(&format!(" <caption>{caption}</caption>\n"));
|
||||||
|
out.push_str(
|
||||||
|
" <thead><tr><th scope=\"col\">Pts</th><th scope=\"col\">Criterion</th></tr></thead>\n",
|
||||||
|
);
|
||||||
|
out.push_str(" <tbody>\n");
|
||||||
|
for criterion in &solution.rubric {
|
||||||
|
let pts = criterion.points.map(trim_number).unwrap_or_default();
|
||||||
|
out.push_str(&format!(
|
||||||
|
" <tr><td>{pts}</td><td>{}</td></tr>\n",
|
||||||
|
inline_html(&criterion.description)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str(" </tbody>\n</table>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !solution.accepted.is_empty() {
|
||||||
|
let joined = solution
|
||||||
|
.accepted
|
||||||
|
.iter()
|
||||||
|
.map(|a| inline_html(a))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<p class=\"sol-accepted\"><span class=\"badge\">Also accepted</span> {joined}</p>\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
push_reference(&mut out, course, Some(solution));
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The student-facing reason a distractor is wrong: the instructor explanation
|
||||||
|
/// first, then any student feedback.
|
||||||
|
fn why_wrong(choice: &Choice) -> Option<&str> {
|
||||||
|
choice
|
||||||
|
.explanation
|
||||||
|
.as_deref()
|
||||||
|
.or(choice.feedback_student.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends the `Source:` line when the solution carries review citations.
|
||||||
|
fn push_reference(out: &mut String, course: &CourseFile, solution: Option<&Solution>) {
|
||||||
|
let Some(solution) = solution else { return };
|
||||||
|
if solution.review.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let sources = solution
|
||||||
|
.review
|
||||||
|
.iter()
|
||||||
|
.map(|c| cite_html(course, c))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
out.push_str(&format!("<p class=\"sol-ref\">Source: {sources}</p>\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders one citation to HTML, linking it when a URL resolves.
|
||||||
|
fn cite_html(course: &CourseFile, citation: &Citation) -> String {
|
||||||
|
if let Some(text) = &citation.text {
|
||||||
|
if citation.reference.is_none() {
|
||||||
|
return markup::escape_html(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(key) = &citation.reference else {
|
||||||
|
return markup::escape_html(&citation.display());
|
||||||
|
};
|
||||||
|
let Some(reference) = course.references.get(key) else {
|
||||||
|
return markup::escape_html(&citation.display());
|
||||||
|
};
|
||||||
|
let label = reference.label.as_deref().unwrap_or(key);
|
||||||
|
let locator = citation.locator.as_deref().unwrap_or("");
|
||||||
|
let body = if locator.is_empty() {
|
||||||
|
markup::escape_html(label)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} {}",
|
||||||
|
markup::escape_html(label),
|
||||||
|
markup::escape_html(locator)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
match resolve_url(citation, reference) {
|
||||||
|
Some(url) => format!("<a href=\"{}\">{body}</a>", markup::escape_html(&url)),
|
||||||
|
None => body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a citation's link, from an explicit URL or a path joined to the
|
||||||
|
/// reference's base URL.
|
||||||
|
fn resolve_url(citation: &Citation, reference: &Reference) -> Option<String> {
|
||||||
|
if let Some(url) = &citation.url {
|
||||||
|
return Some(url.clone());
|
||||||
|
}
|
||||||
|
let path = citation.path.as_deref()?;
|
||||||
|
let base = reference.base_url.as_deref()?;
|
||||||
|
Some(match (base.ends_with('/'), path.starts_with('/')) {
|
||||||
|
(true, true) => format!("{base}{}", &path[1..]),
|
||||||
|
(false, false) => format!("{base}/{path}"),
|
||||||
|
_ => format!("{base}{path}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- math-aware markup ---
|
||||||
|
|
||||||
|
/// One run of source text, split on math delimiters.
|
||||||
|
enum Segment<'a> {
|
||||||
|
/// Prose, formatted with the shared escape, symbol, and inline rules.
|
||||||
|
Text(&'a str),
|
||||||
|
/// A math span, passed through with its interior escaped for the browser.
|
||||||
|
Math { inner: &'a str, display: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits source into prose and `$…$` or `$$…$$` math runs.
|
||||||
|
///
|
||||||
|
/// The split runs on raw source so a formatting rule can never reach inside math:
|
||||||
|
/// an underscore in `$q_p$` is a subscript, not the start of an emphasis span.
|
||||||
|
fn split_math(src: &str) -> Vec<Segment<'_>> {
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
let mut rest = src;
|
||||||
|
while let Some(at) = rest.find('$') {
|
||||||
|
if at > 0 {
|
||||||
|
segments.push(Segment::Text(&rest[..at]));
|
||||||
|
}
|
||||||
|
let after = &rest[at..];
|
||||||
|
if let Some(display) = after.strip_prefix("$$") {
|
||||||
|
if let Some(end) = display.find("$$") {
|
||||||
|
segments.push(Segment::Math {
|
||||||
|
inner: &display[..end],
|
||||||
|
display: true,
|
||||||
|
});
|
||||||
|
rest = &display[end + 2..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let inline = &after[1..];
|
||||||
|
match inline.find('$') {
|
||||||
|
Some(end) => {
|
||||||
|
segments.push(Segment::Math {
|
||||||
|
inner: &inline[..end],
|
||||||
|
display: false,
|
||||||
|
});
|
||||||
|
rest = &inline[end + 1..];
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// An unterminated `$` is treated as ordinary text.
|
||||||
|
segments.push(Segment::Text(after));
|
||||||
|
rest = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !rest.is_empty() {
|
||||||
|
segments.push(Segment::Text(rest));
|
||||||
|
}
|
||||||
|
segments
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a prose run: HTML-escape, then the symbol table and inline markup.
|
||||||
|
fn format_prose(text: &str) -> String {
|
||||||
|
markup::apply_inline(&markup::apply_symbols(&markup::escape_html(text), true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits a math run with its delimiters, escaping the interior so the browser
|
||||||
|
/// hands MathJax clean text (a `<` inside math becomes `<`, which the DOM
|
||||||
|
/// decodes back before MathJax reads it).
|
||||||
|
fn render_math(inner: &str, display: bool) -> String {
|
||||||
|
let delim = if display { "$$" } else { "$" };
|
||||||
|
format!("{delim}{}{delim}", markup::escape_html(inner))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts authoring markup to an inline HTML string, preserving math.
|
||||||
|
///
|
||||||
|
/// No paragraph wrapping: the caller supplies the surrounding element.
|
||||||
|
fn inline_html(src: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for segment in split_math(src.trim()) {
|
||||||
|
match segment {
|
||||||
|
Segment::Text(text) => out.push_str(&format_prose(text)),
|
||||||
|
Segment::Math { inner, display } => out.push_str(&render_math(inner, display)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`inline_html`], but wraps each blank-line-separated paragraph in `<p>`.
|
||||||
|
fn block_html(src: &str) -> String {
|
||||||
|
src.split("\n\n")
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.map(|p| format!("<p>{}</p>", inline_html(p)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the encrypted bundle -----
|
||||||
|
|
||||||
|
/// The encrypted solutions bundle, matching the `solutions.js` v1 format.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Bundle {
|
||||||
|
v: u8,
|
||||||
|
page: String,
|
||||||
|
kdf: Kdf,
|
||||||
|
cipher: &'static str,
|
||||||
|
items: OrderedItems,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
struct Kdf {
|
||||||
|
name: &'static str,
|
||||||
|
hash: &'static str,
|
||||||
|
iterations: u32,
|
||||||
|
salt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
struct Enc {
|
||||||
|
iv: String,
|
||||||
|
ct: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Item entries serialized as a JSON object in insertion order, so the bundle
|
||||||
|
/// lists solutions in the order the questions appear rather than sorted by id.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct OrderedItems(Vec<(String, Enc)>);
|
||||||
|
|
||||||
|
impl Serialize for OrderedItems {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
|
||||||
|
let mut map = serializer.serialize_map(Some(self.0.len()))?;
|
||||||
|
for (id, enc) in &self.0 {
|
||||||
|
map.serialize_entry(id, enc)?;
|
||||||
|
}
|
||||||
|
map.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a random per-bundle password.
|
||||||
|
///
|
||||||
|
/// Sixteen Crockford base32 symbols, grouped in fours, for eighty bits of entropy
|
||||||
|
/// from the system CSPRNG, for example `k7m4-9p2q-r8tx-3wn6`.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The password, to print for the instructor.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::Other`] if the system CSPRNG is unavailable.
|
||||||
|
pub fn gen_password() -> Result<String> {
|
||||||
|
let mut raw = [0u8; 16];
|
||||||
|
csprng(&mut raw)?;
|
||||||
|
// Thirty-two divides 256, so the modulo is unbiased.
|
||||||
|
let symbols: Vec<u8> = raw
|
||||||
|
.iter()
|
||||||
|
.map(|b| PW_ALPHABET[(*b % 32) as usize])
|
||||||
|
.collect();
|
||||||
|
let groups: Vec<String> = symbols
|
||||||
|
.chunks(4)
|
||||||
|
.map(|c| String::from_utf8_lossy(c).into_owned())
|
||||||
|
.collect();
|
||||||
|
Ok(groups.join("-"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypts rendered fragments into a bundle.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `page` - the assessment id, stored as `page` and echoed by the script.
|
||||||
|
/// * `password` - the password to derive the key from.
|
||||||
|
/// * `fragments` - item id and solution HTML, in the order to list them.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The bundle, ready to serialize as `<page>-solutions.json`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::Other`] on a CSPRNG or encryption failure.
|
||||||
|
pub fn build_bundle(page: &str, password: &str, fragments: &[(String, String)]) -> Result<Bundle> {
|
||||||
|
let mut salt = [0u8; 16];
|
||||||
|
csprng(&mut salt)?;
|
||||||
|
let key = derive_key(password, &salt);
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||||
|
.map_err(|_| Error::other("the derived AES key was the wrong length"))?;
|
||||||
|
|
||||||
|
let mut items = Vec::with_capacity(fragments.len());
|
||||||
|
for (id, html) in fragments {
|
||||||
|
let mut iv = [0u8; 12];
|
||||||
|
csprng(&mut iv)?;
|
||||||
|
let nonce = GenericArray::from_slice(&iv);
|
||||||
|
let ct = cipher
|
||||||
|
.encrypt(nonce, html.as_bytes())
|
||||||
|
.map_err(|_| Error::other("AES-GCM encryption failed"))?;
|
||||||
|
items.push((
|
||||||
|
id.clone(),
|
||||||
|
Enc {
|
||||||
|
iv: B64.encode(iv),
|
||||||
|
ct: B64.encode(ct),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Bundle {
|
||||||
|
v: 1,
|
||||||
|
page: page.to_string(),
|
||||||
|
kdf: Kdf {
|
||||||
|
name: "PBKDF2",
|
||||||
|
hash: "SHA-256",
|
||||||
|
iterations: ITERATIONS,
|
||||||
|
salt: B64.encode(salt),
|
||||||
|
},
|
||||||
|
cipher: "AES-GCM",
|
||||||
|
items: OrderedItems(items),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the AES-256 key with PBKDF2-HMAC-SHA256.
|
||||||
|
fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] {
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
pbkdf2::pbkdf2_hmac::<sha2::Sha256>(password.as_bytes(), salt, ITERATIONS, &mut key);
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fills a buffer with cryptographically secure random bytes.
|
||||||
|
fn csprng(buf: &mut [u8]) -> Result<()> {
|
||||||
|
getrandom::getrandom(buf).map_err(|e| Error::other(format!("system CSPRNG unavailable: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- small helpers -------
|
||||||
|
|
||||||
|
/// Formats a point value: no decimal when whole, at most two places otherwise.
|
||||||
|
fn trim_number(value: f64) -> String {
|
||||||
|
if value.fract() == 0.0 {
|
||||||
|
format!("{}", value as i64)
|
||||||
|
} else {
|
||||||
|
let s = format!("{value:.2}");
|
||||||
|
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The printed letter for a position: 0 is A, 1 is B, and so on.
|
||||||
|
fn letter(position: usize) -> char {
|
||||||
|
char::from(b'A' + (position % 26) as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn form() -> Form {
|
||||||
|
Form {
|
||||||
|
id: "A".into(),
|
||||||
|
seed: 0,
|
||||||
|
shuffle_items: false,
|
||||||
|
shuffle_options: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn catalog(tag: &str) -> Catalog {
|
||||||
|
let dir = std::env::temp_dir().join(format!("cb-site-{tag}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(dir.join("banks")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.join("course.yaml"),
|
||||||
|
r#"
|
||||||
|
course: { code: BIOSC 1000, title: Biochemistry, term: 2026f }
|
||||||
|
references:
|
||||||
|
kkw:
|
||||||
|
label: KKW
|
||||||
|
title: The molecules of life
|
||||||
|
base_url: https://example.org/kkw/
|
||||||
|
lectures:
|
||||||
|
L1.1: { title: Enthalpy }
|
||||||
|
learning_objectives:
|
||||||
|
lo-enthalpy:
|
||||||
|
text: Define enthalpy and explain the constant-pressure result.
|
||||||
|
lectures: [L1.1]
|
||||||
|
order: 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.join("banks").join("b.yaml"),
|
||||||
|
r#"
|
||||||
|
bank: { id: b, title: Bank }
|
||||||
|
items:
|
||||||
|
- id: q-mcq
|
||||||
|
status: draft
|
||||||
|
level: 2
|
||||||
|
format: single_best_answer
|
||||||
|
stem: "The heat at constant pressure equals a change in what?"
|
||||||
|
learning_objectives: [lo-enthalpy]
|
||||||
|
options:
|
||||||
|
- { id: A, text: "Enthalpy, $\\Delta H$", correct: true, feedback_student: "Right, $q_p = \\Delta H$." }
|
||||||
|
- { id: B, text: "Internal energy, $\\Delta U$", misconception: "Uses the constant-volume result.", explanation: "That holds only at constant volume." }
|
||||||
|
solution:
|
||||||
|
model_answer: "The change in enthalpy, $\\Delta H$."
|
||||||
|
review:
|
||||||
|
- { ref: kkw, locator: "§6.3" }
|
||||||
|
- id: q-open
|
||||||
|
status: draft
|
||||||
|
level: 3
|
||||||
|
format: open_response
|
||||||
|
stem: "Show why $q_p = \\Delta H$."
|
||||||
|
learning_objectives: [lo-enthalpy]
|
||||||
|
solution:
|
||||||
|
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
|
||||||
|
rubric:
|
||||||
|
- { description: "States $H = U + PV$.", points: 1 }
|
||||||
|
- { description: "Reaches $q_p = \\Delta H$.", points: 1 }
|
||||||
|
accepted: ["$q_p = \\Delta H$ via $H = U + PV$"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Catalog::load(&dir).expect("catalog loads")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record() -> AssessmentFile {
|
||||||
|
use crate::assessment::{Assessment, Kind, Platform};
|
||||||
|
AssessmentFile {
|
||||||
|
schema_version: "1.0".into(),
|
||||||
|
assessment: Assessment {
|
||||||
|
id: "a1.1".into(),
|
||||||
|
title: "Homework 1".into(),
|
||||||
|
term: None,
|
||||||
|
kind: Kind::Homework,
|
||||||
|
date: None,
|
||||||
|
platform: Platform::Other,
|
||||||
|
minutes_allowed: None,
|
||||||
|
attempts: None,
|
||||||
|
shuffle: None,
|
||||||
|
scoring_policy: None,
|
||||||
|
instructions: None,
|
||||||
|
notes: None,
|
||||||
|
},
|
||||||
|
blueprint: None,
|
||||||
|
forms: Vec::new(),
|
||||||
|
items: vec![
|
||||||
|
Placement {
|
||||||
|
number: 1,
|
||||||
|
item: "b::q-mcq".into(),
|
||||||
|
version: None,
|
||||||
|
fingerprint: None,
|
||||||
|
points: Some(1.0),
|
||||||
|
bonus: false,
|
||||||
|
key: vec!["A".into()],
|
||||||
|
level: None,
|
||||||
|
learning_objectives: Vec::new(),
|
||||||
|
credit_overrides: Default::default(),
|
||||||
|
dropped: false,
|
||||||
|
},
|
||||||
|
Placement {
|
||||||
|
number: 2,
|
||||||
|
item: "b::q-open".into(),
|
||||||
|
version: None,
|
||||||
|
fingerprint: None,
|
||||||
|
points: Some(2.0),
|
||||||
|
bonus: false,
|
||||||
|
key: Vec::new(),
|
||||||
|
level: None,
|
||||||
|
learning_objectives: Vec::new(),
|
||||||
|
credit_overrides: Default::default(),
|
||||||
|
dropped: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn questions_partial_has_no_front_matter_and_no_answers() {
|
||||||
|
let out = questions_qmd(&catalog("qmd"), &record(), &form(), "a1.1").unwrap();
|
||||||
|
assert!(!out.starts_with("---"), "a partial carries no front matter");
|
||||||
|
assert!(out.contains("::: {.solutions-gate data-bundle=\"a1.1-solutions.json\"}"));
|
||||||
|
assert!(out.contains("::: {.q #q-mcq}"));
|
||||||
|
assert!(
|
||||||
|
out.contains("[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points}")
|
||||||
|
);
|
||||||
|
// Choices are printed without letters; the correct flag never appears.
|
||||||
|
assert!(out.contains("1. Enthalpy, $\\Delta H$"));
|
||||||
|
assert!(!out.contains("correct"));
|
||||||
|
// The open-response question gets a writing box, both get an empty slot.
|
||||||
|
assert!(out.contains(":::: {.q-response aria-hidden=\"true\"}"));
|
||||||
|
assert!(out.contains(":::: {.qsol data-solution-for=\"q-mcq\" hidden=\"true\"}"));
|
||||||
|
assert!(out.contains("[2 points]{.q-points}"));
|
||||||
|
// No model answer leaks into the questions.
|
||||||
|
assert!(!out.contains("sol-model"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_choice_fragment_marks_the_key_and_explains_the_distractor() {
|
||||||
|
let cat = catalog("choice");
|
||||||
|
let rec = record();
|
||||||
|
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
|
||||||
|
let mcq = &frags.iter().find(|(id, _)| id == "q-mcq").unwrap().1;
|
||||||
|
assert!(mcq.contains("<span class=\"badge badge-correct\">Correct answer</span>"));
|
||||||
|
assert!(mcq.contains("<b>A</b> — Enthalpy, $\\Delta H$"));
|
||||||
|
assert!(mcq.contains(
|
||||||
|
"<div class=\"sol-model\"><p>The change in enthalpy, $\\Delta H$.</p></div>"
|
||||||
|
));
|
||||||
|
assert!(mcq.contains("<span class=\"opt\">B</span>"));
|
||||||
|
assert!(mcq.contains("<span class=\"opt-mis\">Uses the constant-volume result.</span>"));
|
||||||
|
assert!(mcq.contains("<span class=\"opt-why\">That holds only at constant volume.</span>"));
|
||||||
|
assert!(mcq.contains("<p class=\"sol-ref\">Source: KKW §6.3</p>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_open_fragment_has_a_rubric_table_with_a_total() {
|
||||||
|
let cat = catalog("open");
|
||||||
|
let rec = record();
|
||||||
|
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
|
||||||
|
let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1;
|
||||||
|
assert!(open.contains("<caption>Rubric — 2 points</caption>"));
|
||||||
|
assert!(open.contains("<td>1</td><td>States $H = U + PV$.</td>"));
|
||||||
|
assert!(open.contains("<span class=\"badge\">Also accepted</span>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_html_keeps_math_verbatim_and_escapes_prose() {
|
||||||
|
// A subscript inside math survives; angle brackets outside math are escaped.
|
||||||
|
assert_eq!(inline_html("value $q_p$ < 5"), "value $q_p$ < 5");
|
||||||
|
// Bold outside math becomes a tag; a dollar-math run is passed through.
|
||||||
|
assert_eq!(
|
||||||
|
inline_html("**H** is $H = U + PV$"),
|
||||||
|
"<strong>H</strong> is $H = U + PV$"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_bundle_round_trips_through_the_kdf_and_cipher() {
|
||||||
|
use aes_gcm::aead::generic_array::GenericArray;
|
||||||
|
let fragments = vec![
|
||||||
|
("q-mcq".to_string(), "<p>alpha</p>".to_string()),
|
||||||
|
("q-open".to_string(), "<p>beta</p>".to_string()),
|
||||||
|
];
|
||||||
|
let bundle = build_bundle("a1.1", "enthalpy2026", &fragments).unwrap();
|
||||||
|
assert_eq!(bundle.v, 1);
|
||||||
|
assert_eq!(bundle.page, "a1.1");
|
||||||
|
assert_eq!(bundle.cipher, "AES-GCM");
|
||||||
|
assert_eq!(bundle.kdf.iterations, ITERATIONS);
|
||||||
|
// Insertion order is preserved in the serialized object.
|
||||||
|
let json = serde_json::to_string(&bundle).unwrap();
|
||||||
|
assert!(json.find("q-mcq").unwrap() < json.find("q-open").unwrap());
|
||||||
|
|
||||||
|
// Decrypt the first item the way the browser would and check the plaintext.
|
||||||
|
let salt = B64.decode(&bundle.kdf.salt).unwrap();
|
||||||
|
let key = derive_key("enthalpy2026", &salt);
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
|
||||||
|
let (_, enc) = &bundle.items.0[0];
|
||||||
|
let iv = B64.decode(&enc.iv).unwrap();
|
||||||
|
let ct = B64.decode(&enc.ct).unwrap();
|
||||||
|
let pt = cipher
|
||||||
|
.decrypt(GenericArray::from_slice(&iv), ct.as_ref())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(String::from_utf8(pt).unwrap(), "<p>alpha</p>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_generated_password_has_the_expected_shape() {
|
||||||
|
let pw = gen_password().unwrap();
|
||||||
|
assert_eq!(pw.len(), 19); // 16 symbols + 3 dashes
|
||||||
|
assert_eq!(pw.matches('-').count(), 3);
|
||||||
|
assert!(
|
||||||
|
pw.chars()
|
||||||
|
.all(|c| c == '-' || PW_ALPHABET.contains(&(c as u8)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_two_browser_assets_are_bundled() {
|
||||||
|
let assets = assets();
|
||||||
|
assert_eq!(assets[0].0, "questions.css");
|
||||||
|
assert_eq!(assets[1].0, "solutions.js");
|
||||||
|
assert!(assets[0].1.contains(".qsol"));
|
||||||
|
assert!(assets[1].1.contains("AES-GCM"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -21,7 +21,9 @@
|
|||||||
//! 3. [`first_exam`] runs one exam end to end: assemble, export, administer,
|
//! 3. [`first_exam`] runs one exam end to end: assemble, export, administer,
|
||||||
//! ingest, analyze, report.
|
//! ingest, analyze, report.
|
||||||
//! 4. [`typst_export`] covers printed output, template markers, and render config.
|
//! 4. [`typst_export`] covers printed output, template markers, and render config.
|
||||||
//! 5. [`recipes`] holds short answers to specific questions, for when you already
|
//! 5. [`assignment`] publishes a homework to the course website, with solutions
|
||||||
|
//! gated behind a password.
|
||||||
|
//! 6. [`recipes`] holds short answers to specific questions, for when you already
|
||||||
//! know the shape of the tool.
|
//! know the shape of the tool.
|
||||||
//!
|
//!
|
||||||
//! ## Why the tutorials are in here rather than a wiki
|
//! ## Why the tutorials are in here rather than a wiki
|
||||||
@@ -55,6 +57,10 @@ pub mod first_exam {}
|
|||||||
#[doc = include_str!("../docs/TYPST.md")]
|
#[doc = include_str!("../docs/TYPST.md")]
|
||||||
pub mod typst_export {}
|
pub mod typst_export {}
|
||||||
|
|
||||||
|
/// Publishing an assignment to the website, with solutions gated behind a password.
|
||||||
|
#[doc = include_str!("../docs/guide/assignment.md")]
|
||||||
|
pub mod assignment {}
|
||||||
|
|
||||||
/// Short answers to specific questions.
|
/// Short answers to specific questions.
|
||||||
#[doc = include_str!("../docs/guide/recipes.md")]
|
#[doc = include_str!("../docs/guide/recipes.md")]
|
||||||
pub mod recipes {}
|
pub mod recipes {}
|
||||||
|
|||||||
+1
-1
@@ -104,12 +104,12 @@ pub use model::{assessment, bank, catalog, course, history, item, layout, taxono
|
|||||||
|
|
||||||
pub use authoring::{jsonschema, lint, select};
|
pub use authoring::{jsonschema, lint, select};
|
||||||
|
|
||||||
#[cfg(feature = "parquet")]
|
|
||||||
pub use data::store_parquet;
|
pub use data::store_parquet;
|
||||||
pub use data::{canvas, gradescope, responses, store};
|
pub use data::{canvas, gradescope, responses, store};
|
||||||
|
|
||||||
pub use analysis::{calibrate, classical, irt, students};
|
pub use analysis::{calibrate, classical, irt, students};
|
||||||
|
|
||||||
|
pub use export::site;
|
||||||
pub use export::{lecture, practice, qti, report, typst};
|
pub use export::{lecture, practice, qti, report, typst};
|
||||||
|
|
||||||
pub use catalog::Catalog;
|
pub use catalog::Catalog;
|
||||||
|
|||||||
+6
-6
@@ -352,7 +352,7 @@ fn validate_item(
|
|||||||
issues.push("version must be at least 1".into());
|
issues.push("version must be at least 1".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- options ---
|
// --- options ----
|
||||||
// An open-response item takes no options; its answer lives in `solution`.
|
// An open-response item takes no options; its answer lives in `solution`.
|
||||||
// Every other format needs at least two things to choose between.
|
// Every other format needs at least two things to choose between.
|
||||||
if it.format.has_options() {
|
if it.format.has_options() {
|
||||||
@@ -428,7 +428,7 @@ fn validate_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- key -----
|
// --- key ---
|
||||||
let keys = it.key_indices();
|
let keys = it.key_indices();
|
||||||
match it.format {
|
match it.format {
|
||||||
Format::SingleBestAnswer => {
|
Format::SingleBestAnswer => {
|
||||||
@@ -465,7 +465,7 @@ fn validate_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- level and process must agree --------
|
// --- level and process must agree -------
|
||||||
if let Some(p) = it.cognitive_process {
|
if let Some(p) = it.cognitive_process {
|
||||||
if !it.level.allows(p) {
|
if !it.level.allows(p) {
|
||||||
issues.push(format!(
|
issues.push(format!(
|
||||||
@@ -476,7 +476,7 @@ fn validate_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- design plausibility -----------
|
// --- design plausibility ------
|
||||||
if let Some(d) = &it.design {
|
if let Some(d) = &it.design {
|
||||||
if let Some(x) = d.expected_difficulty {
|
if let Some(x) = d.expected_difficulty {
|
||||||
if !(0.0..=1.0).contains(&x) {
|
if !(0.0..=1.0).contains(&x) {
|
||||||
@@ -548,7 +548,7 @@ fn validate_item(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- retirement ----
|
// --- retirement -----
|
||||||
if it.retired.is_some() && it.status != Status::Retired {
|
if it.retired.is_some() && it.status != Status::Retired {
|
||||||
issues.push(format!(
|
issues.push(format!(
|
||||||
"has a `retired` block but status is `{}`",
|
"has a `retired` block but status is `{}`",
|
||||||
@@ -588,7 +588,7 @@ fn validate_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- cross-file references ---------
|
// --- cross-file references ----
|
||||||
if let Some(c) = course {
|
if let Some(c) = course {
|
||||||
for lo in &it.learning_objectives {
|
for lo in &it.learning_objectives {
|
||||||
match c.learning_objectives.get(lo) {
|
match c.learning_objectives.get(lo) {
|
||||||
|
|||||||
+2
-2
@@ -199,7 +199,7 @@ pub fn escape_html(s: &str) -> String {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// The substituted text.
|
/// The substituted text.
|
||||||
fn apply_symbols(s: &str, html: bool) -> String {
|
pub(crate) fn apply_symbols(s: &str, html: bool) -> String {
|
||||||
let mut out = s.to_string();
|
let mut out = s.to_string();
|
||||||
for (token, entity, plain) in SYMBOLS {
|
for (token, entity, plain) in SYMBOLS {
|
||||||
if out.contains(token) {
|
if out.contains(token) {
|
||||||
@@ -218,7 +218,7 @@ fn apply_symbols(s: &str, html: bool) -> String {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// The text with inline markup converted.
|
/// The text with inline markup converted.
|
||||||
fn apply_inline(s: &str) -> String {
|
pub(crate) fn apply_inline(s: &str) -> String {
|
||||||
let mut out = s.to_string();
|
let mut out = s.to_string();
|
||||||
// Bracketed forms first: their contents may contain other markup characters.
|
// Bracketed forms first: their contents may contain other markup characters.
|
||||||
out = wrap_bracket(&out, "#sub[", "<sub>", "</sub>");
|
out = wrap_bracket(&out, "#sub[", "<sub>", "</sub>");
|
||||||
|
|||||||
Reference in New Issue
Block a user