feat: splitting

This commit is contained in:
2026-09-26 01:15:12 -04:00
parent 220363d4d3
commit eabc98ad31
29 changed files with 4843 additions and 234 deletions
+142 -9
View File
@@ -31,6 +31,12 @@ const BASE: &str = "https://coursebank.dev/schema";
pub enum Kind {
/// `course.yaml`.
Course,
/// `references.yaml`.
References,
/// `lectures/*.yaml`.
Lecture,
/// `objectives/*.yaml`.
Objective,
/// `banks/*.yaml`.
Bank,
/// `assessments/*.yaml`.
@@ -38,13 +44,23 @@ pub enum Kind {
}
impl Kind {
/// All three kinds.
pub const ALL: [Kind; 3] = [Kind::Course, Kind::Bank, Kind::Assessment];
/// Every kind.
pub const ALL: [Kind; 6] = [
Kind::Course,
Kind::References,
Kind::Lecture,
Kind::Objective,
Kind::Bank,
Kind::Assessment,
];
/// The file name a schema is written to.
pub fn filename(self) -> &'static str {
match self {
Kind::Course => "course.schema.json",
Kind::References => "references.schema.json",
Kind::Lecture => "lecture.schema.json",
Kind::Objective => "objective.schema.json",
Kind::Bank => "bank.schema.json",
Kind::Assessment => "assessment.schema.json",
}
@@ -80,12 +96,15 @@ impl Kind {
pub fn schema(kind: Kind) -> Value {
match kind {
Kind::Course => course_schema(),
Kind::References => references_schema(),
Kind::Lecture => lecture_fragment_schema(),
Kind::Objective => objective_fragment_schema(),
Kind::Bank => bank_schema(),
Kind::Assessment => assessment_schema(),
}
}
/// Writes all three schemas to a directory.
/// Writes every schema to a directory.
///
/// # Arguments
///
@@ -328,6 +347,13 @@ fn lecture_schema() -> Value {
"date": date("Date delivered."),
"unit": { "type": "string", "description": "Unit id." },
"slides_url": { "type": "string" },
"teaches": {
"type": "array",
"description": "The objectives this session develops. Each named objective gains \
this lecture in its `lectures` list when the course is loaded, \
so the pair is declared once, here, while planning the lecture.",
"items": { "type": "string" }
},
"readings": {
"type": "array",
"description": "Readings assigned with this lecture, in the order you assign \
@@ -434,7 +460,23 @@ fn reference_schema() -> Value {
"volume": { "type": "string" },
"issue": { "type": "string" },
"pages": { "type": "string", "description": "Pages of the work, not of a reading." },
"doi": { "type": "string", "description": "Bare DOI: 10.1038/nature12373." },
"doi": {
"type": "string",
"pattern": "^(doi:|https?://(dx\\.)?doi\\.org/)?10\\.",
"description": "Bare DOI: 10.1038/nature12373. For a manuscript this is \
usually the only link worth storing, since a reading list \
resolves it to doi.org."
},
"arxiv": { "type": "string", "description": "Bare arXiv id: 2301.00001." },
"pmcid": {
"type": "string",
"description": "PubMed Central id, which hosts the full text: PMC3084216."
},
"pmid": {
"type": "string",
"pattern": "^[0-9]+$",
"description": "PubMed id, which hosts a record about the work: 21471563."
},
"isbn": { "type": "string" },
"url": { "type": "string", "description": "Canonical URL for the whole work." },
"base_url": {
@@ -568,6 +610,94 @@ fn course_schema() -> Value {
})
}
/// The schema for `references.yaml`.
fn references_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": format!("{BASE}/references.schema.json"),
"title": "coursebank references file",
"description": "The works the course cites, by citation key. One fragment of the \
course file; see course.schema.json for the whole.",
"type": "object",
"additionalProperties": false,
"properties": {
"schema_version": {
"type": ["string", "number"],
"description": format!("Format version; currently {SCHEMA_VERSION}. Declared in \
course.yaml; fragments inherit it.")
},
"references": {
"type": "object",
"description": "Works by citation key.",
"additionalProperties": reference_schema()
}
}
})
}
/// The schema for one file under `lectures/`.
fn lecture_fragment_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": format!("{BASE}/lecture.schema.json"),
"title": "coursebank lecture file",
"description": "One session: its readings, and the objectives it develops. A fragment \
of the course file, merged on load.",
"type": "object",
"required": ["lectures"],
"additionalProperties": false,
"properties": {
"schema_version": {
"type": ["string", "number"],
"description": "Declared in course.yaml; fragments inherit it."
},
"lectures": {
"type": "object",
"description": "Keyed by lecture id, conventionally one entry per file.",
"additionalProperties": lecture_schema()
}
}
})
}
/// The schema for one file under `objectives/`.
fn objective_fragment_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": format!("{BASE}/objective.schema.json"),
"title": "coursebank objective file",
"description": "One learning objective and the learning targets it decomposes into. A \
fragment of the course file, merged on load.",
"type": "object",
"required": ["learning_objectives"],
"additionalProperties": false,
"properties": {
"schema_version": {
"type": ["string", "number"],
"description": "Declared in course.yaml; fragments inherit it."
},
"learning_objectives": {
"type": "object",
"description": "Keyed by objective id, conventionally one entry per file. Its \
`lectures` list is derived from each lecture's `teaches`, so \
leave it out unless you prefer to declare it here.",
"additionalProperties": objective_schema()
},
"learning_targets": {
"type": "object",
"description": "The targets of this file's objective, by id. A target with no \
`lectures` of its own inherits its objective's.",
"additionalProperties": target_schema()
},
"stimuli": {
"type": "object",
"description": "Shared passages, figures, or data that several items refer to.",
"additionalProperties": stimulus_schema()
}
}
})
}
/// One option's schema.
///
/// Split out from [`item_schema`] rather than inlined, because `serde_json`'s
@@ -583,9 +713,11 @@ fn option_schema() -> Value {
"properties": {
"id": {
"type": "string",
"pattern": "^[A-H]$",
"description": "Option letter. Identity, not print position — shuffled forms \
relabel on the way out."
"pattern": "^(o-[a-z0-9]+(-[a-z0-9]+)*|[A-H])$",
"description": "Option id, unique within the item: `o-fourth-line`. An \
identity, not a print position — shuffled forms relabel on the \
way out. A single letter A-H is the pre-2.0 form; \
`coursebank migrate options` renames it."
},
"text": text("The option as a student reads it."),
"correct": { "type": "boolean" },
@@ -1260,7 +1392,8 @@ mod tests {
assert_eq!(props["options"]["maxItems"], 8);
assert_eq!(
props["options"]["items"]["properties"]["id"]["pattern"],
"^[A-H]$"
// Either form: the 2.0 name, or the letter it replaces.
"^(o-[a-z0-9]+(-[a-z0-9]+)*|[A-H])$"
);
}
@@ -1283,7 +1416,7 @@ mod tests {
let dir = std::env::temp_dir().join(format!("cb-schema-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
let written = write_all(&dir).unwrap();
assert_eq!(written.len(), 3);
assert_eq!(written.len(), Kind::ALL.len());
for path in &written {
assert!(path.exists());
let text = std::fs::read_to_string(path).unwrap();
+1 -1
View File
@@ -1196,7 +1196,7 @@ mod tests {
fn entry(yaml: &str) -> Entry {
let item: Item = serde_yaml_ng::from_str(yaml).expect("item parses");
Entry {
uid: format!("b::{}", item.id),
uid: item.id.clone(),
bank: "b".into(),
path: PathBuf::from("b.yaml"),
index: 0,
+2 -1
View File
@@ -469,7 +469,8 @@ pub fn to_record(
items.push(Placement {
number,
item: uid.clone(),
version: Some(e.item.version),
version: None,
stem_digest: Some(e.item.stem_digest()),
fingerprint: Some(e.item.fingerprint()),
points: Some(e.item.points(default_points)),
bonus: is_bonus || e.item.bonus,