forked from EduCraft/curriculum-project-hub
feat(cph): add outline export command
This commit is contained in:
+169
-8
@@ -60,6 +60,17 @@ pub struct Lesson {
|
||||
}
|
||||
|
||||
impl Lesson {
|
||||
/// Project the nested depth-first outline into the target-independent
|
||||
/// document shape used by `cph outline`.
|
||||
pub fn outline_document(&self) -> OutlineDocument {
|
||||
let (children, _) = consume_outline_children(self, 0, 0);
|
||||
OutlineDocument {
|
||||
title: self.info.title.clone(),
|
||||
authors: self.info.authors.clone(),
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
/// The declared export-target names, in declared order.
|
||||
pub fn target_names(&self) -> Vec<&str> {
|
||||
self.targets.iter().map(|t| t.name.as_str()).collect()
|
||||
@@ -67,12 +78,14 @@ impl Lesson {
|
||||
}
|
||||
|
||||
/// One entry in the lesson's full rendering-order sequence (ADR-0029).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub enum OutlineEntry {
|
||||
/// An element at this position: `part_index` into [`Lesson::parts`].
|
||||
Element {
|
||||
/// Index into `Lesson::parts`.
|
||||
part_index: usize,
|
||||
/// Container depth: 0 for a root child, 1 inside a direct section, etc.
|
||||
depth: u32,
|
||||
},
|
||||
/// A section container opens here. Contributes no element; it is a
|
||||
/// heading, not a part (ADR-0029). `depth` is the section's nesting depth
|
||||
@@ -84,6 +97,8 @@ pub enum OutlineEntry {
|
||||
/// Heading text: the container's `[group].title` if present and
|
||||
/// non-empty, else the folder's basename.
|
||||
title: String,
|
||||
/// Optional teacher-facing planning note from the `children` entry.
|
||||
notes: Option<String>,
|
||||
/// Nesting depth (1-based) — the heading level a renderer should use.
|
||||
depth: u32,
|
||||
/// The container folder's path, relative to the engineering-file root.
|
||||
@@ -91,6 +106,95 @@ pub enum OutlineEntry {
|
||||
},
|
||||
}
|
||||
|
||||
/// A target-independent outline projection of a nested lesson.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct OutlineDocument {
|
||||
/// Course title from the root `[info].title`.
|
||||
pub title: String,
|
||||
/// Authors from the root `[info].author`.
|
||||
pub authors: Vec<String>,
|
||||
/// Ordered root children, preserving nested sections.
|
||||
pub children: Vec<OutlineNode>,
|
||||
}
|
||||
|
||||
/// One section or element in the exported outline tree.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct OutlineNode {
|
||||
/// Heading text shown to a teacher.
|
||||
pub title: String,
|
||||
/// Element or container kind.
|
||||
pub kind: String,
|
||||
/// Root-relative source path.
|
||||
pub path: PathBuf,
|
||||
/// Optional teacher-facing planning note.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
/// Nested children; empty for an element leaf.
|
||||
pub children: Vec<OutlineNode>,
|
||||
}
|
||||
|
||||
impl OutlineNode {
|
||||
fn from_part(part: &Part) -> Self {
|
||||
let title = part
|
||||
.path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| part.path.to_string_lossy().into_owned());
|
||||
Self {
|
||||
title,
|
||||
kind: part.kind.clone(),
|
||||
path: part.path.clone(),
|
||||
notes: part.notes.clone().filter(|notes| !notes.trim().is_empty()),
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the flat depth-first sequence into nested JSON/format nodes.
|
||||
fn consume_outline_children(
|
||||
lesson: &Lesson,
|
||||
start: usize,
|
||||
minimum_section_depth: u32,
|
||||
) -> (Vec<OutlineNode>, usize) {
|
||||
let mut children = Vec::new();
|
||||
let mut index = start;
|
||||
while index < lesson.outline.len() {
|
||||
match &lesson.outline[index] {
|
||||
OutlineEntry::Element { part_index, depth } => {
|
||||
if *depth < minimum_section_depth {
|
||||
break;
|
||||
}
|
||||
children.push(OutlineNode::from_part(&lesson.parts[*part_index]));
|
||||
index += 1;
|
||||
}
|
||||
OutlineEntry::Section {
|
||||
kind,
|
||||
title,
|
||||
notes,
|
||||
depth,
|
||||
path,
|
||||
} => {
|
||||
if *depth < minimum_section_depth {
|
||||
break;
|
||||
}
|
||||
let section_depth = *depth;
|
||||
let (nested, next) = consume_outline_children(lesson, index + 1, section_depth);
|
||||
children.push(OutlineNode {
|
||||
title: title.clone(),
|
||||
kind: kind.clone(),
|
||||
path: path.clone(),
|
||||
notes: notes.clone().filter(|note| !note.trim().is_empty()),
|
||||
children: nested,
|
||||
});
|
||||
index = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
(children, index)
|
||||
}
|
||||
|
||||
/// One declared export target's build config (ADR-0009/0011).
|
||||
///
|
||||
/// An export target is a **build** producing a typed [`Artifact`] via an
|
||||
@@ -289,6 +393,9 @@ pub struct Part {
|
||||
/// Element folder path, root-relative, kept verbatim (forward/back slashes
|
||||
/// as the OS provides) for diagnostics/display.
|
||||
pub path: PathBuf,
|
||||
/// Optional teacher-facing planning note from the `children` entry.
|
||||
/// This is outline metadata, not a content field in `element.toml`.
|
||||
pub notes: Option<String>,
|
||||
/// The element's self-description loaded from its `element.toml`.
|
||||
pub descriptor: ElementDescriptor,
|
||||
}
|
||||
@@ -380,6 +487,8 @@ struct RawGroup {
|
||||
struct RawChild {
|
||||
kind: String,
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Load the engineering file at `root` into a [`Lesson`].
|
||||
@@ -559,6 +668,7 @@ fn load_children(
|
||||
outline: &mut Vec<OutlineEntry>,
|
||||
) {
|
||||
for child in children {
|
||||
let element_depth = next_section_depth.saturating_sub(1);
|
||||
let local_path = PathBuf::from(&child.path);
|
||||
let full_rel_path = join_rel(rel_prefix, &local_path);
|
||||
|
||||
@@ -571,7 +681,15 @@ fn load_children(
|
||||
)
|
||||
.with_hint("child paths must be relative folders inside the containing folder"),
|
||||
);
|
||||
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
|
||||
push_broken_leaf(
|
||||
parts,
|
||||
outline,
|
||||
child.kind,
|
||||
full_rel_path,
|
||||
root,
|
||||
child.notes,
|
||||
element_depth,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -588,7 +706,15 @@ fn load_children(
|
||||
full_rel_path.display()
|
||||
)),
|
||||
);
|
||||
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
|
||||
push_broken_leaf(
|
||||
parts,
|
||||
outline,
|
||||
child.kind,
|
||||
full_rel_path,
|
||||
root,
|
||||
child.notes,
|
||||
element_depth,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -603,14 +729,26 @@ fn load_children(
|
||||
parts.push(Part {
|
||||
kind: child.kind,
|
||||
path: full_rel_path,
|
||||
notes: child.notes,
|
||||
descriptor,
|
||||
});
|
||||
outline.push(OutlineEntry::Element { part_index: idx });
|
||||
outline.push(OutlineEntry::Element {
|
||||
part_index: idx,
|
||||
depth: element_depth,
|
||||
});
|
||||
}
|
||||
// A container: recurse into its own manifest.toml.
|
||||
(true, false) => {
|
||||
let Some(raw_container) = read_container_manifest(&abs_dir, diags) else {
|
||||
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
|
||||
push_broken_leaf(
|
||||
parts,
|
||||
outline,
|
||||
child.kind,
|
||||
full_rel_path,
|
||||
root,
|
||||
child.notes,
|
||||
element_depth,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -643,6 +781,7 @@ fn load_children(
|
||||
outline.push(OutlineEntry::Section {
|
||||
kind: child.kind,
|
||||
title,
|
||||
notes: child.notes,
|
||||
depth: next_section_depth,
|
||||
path: full_rel_path.clone(),
|
||||
});
|
||||
@@ -671,7 +810,15 @@ fn load_children(
|
||||
)
|
||||
.with_hint("remove one of manifest.toml or element.toml from this folder"),
|
||||
);
|
||||
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
|
||||
push_broken_leaf(
|
||||
parts,
|
||||
outline,
|
||||
child.kind,
|
||||
full_rel_path,
|
||||
root,
|
||||
child.notes,
|
||||
element_depth,
|
||||
);
|
||||
}
|
||||
// Incomplete: neither a container nor a leaf.
|
||||
(false, false) => {
|
||||
@@ -688,7 +835,15 @@ fn load_children(
|
||||
(container) to this folder",
|
||||
),
|
||||
);
|
||||
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
|
||||
push_broken_leaf(
|
||||
parts,
|
||||
outline,
|
||||
child.kind,
|
||||
full_rel_path,
|
||||
root,
|
||||
child.notes,
|
||||
element_depth,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -737,19 +892,25 @@ fn push_broken_leaf(
|
||||
kind: String,
|
||||
path: PathBuf,
|
||||
root: &Path,
|
||||
notes: Option<String>,
|
||||
depth: u32,
|
||||
) {
|
||||
let idx = parts.len();
|
||||
let dir = root.join(&path);
|
||||
parts.push(Part {
|
||||
kind: kind.clone(),
|
||||
path,
|
||||
notes,
|
||||
descriptor: ElementDescriptor {
|
||||
kind,
|
||||
dir,
|
||||
scalars: toml::Table::new(),
|
||||
},
|
||||
});
|
||||
outline.push(OutlineEntry::Element { part_index: idx });
|
||||
outline.push(OutlineEntry::Element {
|
||||
part_index: idx,
|
||||
depth,
|
||||
});
|
||||
}
|
||||
|
||||
/// Join a container-relative child path onto that container's own
|
||||
|
||||
@@ -9,6 +9,7 @@ author = "范式教育教研组"
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
notes = "这一节补充一个直观例题"
|
||||
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
|
||||
@@ -35,6 +35,17 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
|
||||
assert_eq!(lesson.parts.len(), 2);
|
||||
assert_eq!(lesson.parts[0].kind, "segment");
|
||||
assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro"));
|
||||
assert_eq!(
|
||||
lesson.parts[0].notes.as_deref(),
|
||||
Some("这一节补充一个直观例题")
|
||||
);
|
||||
let outline = lesson.outline_document();
|
||||
assert_eq!(outline.children[0].title, "intro");
|
||||
assert_eq!(
|
||||
outline.children[0].notes.as_deref(),
|
||||
Some("这一节补充一个直观例题")
|
||||
);
|
||||
assert!(outline.children[0].children.is_empty());
|
||||
assert_eq!(lesson.parts[0].descriptor.kind, "segment");
|
||||
assert_eq!(lesson.parts[1].kind, "lemma");
|
||||
assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young"));
|
||||
@@ -45,8 +56,14 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
|
||||
assert_eq!(
|
||||
lesson.outline,
|
||||
vec![
|
||||
OutlineEntry::Element { part_index: 0 },
|
||||
OutlineEntry::Element { part_index: 1 },
|
||||
OutlineEntry::Element {
|
||||
part_index: 0,
|
||||
depth: 0,
|
||||
},
|
||||
OutlineEntry::Element {
|
||||
part_index: 1,
|
||||
depth: 0,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -114,22 +131,36 @@ fn nested_sections_flatten_depth_first_with_correct_depths() {
|
||||
assert_eq!(
|
||||
lesson.outline,
|
||||
vec![
|
||||
OutlineEntry::Element { part_index: 0 }, // segments/开场白
|
||||
OutlineEntry::Element {
|
||||
part_index: 0,
|
||||
depth: 0,
|
||||
}, // segments/开场白
|
||||
OutlineEntry::Section {
|
||||
kind: "section".to_string(),
|
||||
title: "导言簇".to_string(),
|
||||
depth: 1,
|
||||
notes: None,
|
||||
path: PathBuf::from("导言簇"),
|
||||
},
|
||||
OutlineEntry::Element { part_index: 1 }, // 导言簇/segments/子段一
|
||||
OutlineEntry::Element {
|
||||
part_index: 1,
|
||||
depth: 1,
|
||||
}, // 导言簇/segments/子段一
|
||||
OutlineEntry::Section {
|
||||
kind: "section".to_string(),
|
||||
title: "嵌套子节".to_string(),
|
||||
depth: 2,
|
||||
notes: None,
|
||||
path: PathBuf::from("导言簇/嵌套子节"),
|
||||
},
|
||||
OutlineEntry::Element { part_index: 2 }, // 导言簇/嵌套子节/lemmas/子引理
|
||||
OutlineEntry::Element { part_index: 3 }, // 导言簇/segments/子段二
|
||||
OutlineEntry::Element {
|
||||
part_index: 2,
|
||||
depth: 2,
|
||||
}, // 导言簇/嵌套子节/lemmas/子引理
|
||||
OutlineEntry::Element {
|
||||
part_index: 3,
|
||||
depth: 1,
|
||||
}, // 导言簇/segments/子段二
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user