1
0
mirror of https://github.com/google/comprehensive-rust.git synced 2025-03-21 06:36:31 +02:00
Dustin J. Mitchell bb44b1d7a8
Use tables to summarize course content (#2005)
This is more friendly to translation (as it can share the translation of
the title).

This fixes #1982.
2024-04-19 13:38:26 +00:00

462 lines
16 KiB
Rust

// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Representation of Comprehensive Rust as a hierarchy of types.
//!
//! ```ignore
//! Courses -- a collection of courses
//! Course -- the level of content at which students enroll (fundamentals, android, etc.)
//! Session -- a block of instructional time (typically morning or afternoon)
//! Segment -- a collection of slides with a related theme
//! Slide -- a single topic (may be represented by multiple mdBook chapters)
//! ```
//!
//! This structure is parsed from the format of the book using a combination of
//! the order in which chapters are listed in `SUMMARY.md` and annotations in
//! the frontmatter of each chapter.
//!
//! A book contains a sequence of BookItems, each of which can contain
//! sub-items. A top-level item can potentially introduce a new course, session,
//! segment, and slide all in the same item. If the item has a `course` property
//! in its frontmatter, then it introduces a new course. If it has a `session`
//! property, then it introduces a new session. A top-level item always
//! corresponds 1-to-1 with a segment (as long as it is a chapter), and that
//! item becomes the first slide in that segment. Any other sub-items of the
//! top-level item are treated as further slides in the same segment.
use crate::frontmatter::{split_frontmatter, Frontmatter};
use crate::markdown::{duration, Table};
use mdbook::book::{Book, BookItem, Chapter};
use std::fmt::Write;
use std::path::PathBuf;
/// Duration, in minutes, of breaks between segments in the course.
const BREAK_DURATION: u64 = 10;
/// Courses is simply a collection of Courses.
///
/// Non-instructional material (such as the introduction) has `course: none` and
/// is not included in this data structure.
#[derive(Default, Debug)]
pub struct Courses {
pub courses: Vec<Course>,
}
/// A Course is the level of content at which students enroll.
///
/// Courses are identified by the `course` property in a session's frontmatter.
/// All sessions with the same value for `course` are grouped into a Course.
#[derive(Default, Debug)]
pub struct Course {
pub name: String,
pub sessions: Vec<Session>,
}
/// A Session is a block of instructional time, containing segments. Typically a
/// full day of instruction contains two sessions: morning and afternoon.
///
/// A session is identified by the `session` property in the session's
/// frontmatter. There can be only one session with a given name in a course.
#[derive(Default, Debug)]
pub struct Session {
pub name: String,
pub segments: Vec<Segment>,
target_minutes: u64,
}
/// A Segment is a collection of slides with a related theme.
///
/// A segment is identified as a top-level chapter within a session.
#[derive(Default, Debug)]
pub struct Segment {
pub name: String,
pub slides: Vec<Slide>,
}
/// A Slide presents a single topic. It may contain multiple mdBook chapters.
///
/// A slide is identified as an sub-chapter of a segment. Any sub-items of
/// that chapter are also included in the slide.
#[derive(Default, Debug)]
pub struct Slide {
pub name: String,
/// Minutes this slide should take to teach.
pub minutes: u64,
/// Source paths (`.md` files) in this slide.
pub source_paths: Vec<PathBuf>,
}
impl Courses {
/// Extract the course structure from the book. As a side-effect, the
/// frontmatter is stripped from each slide.
pub fn extract_structure(mut book: Book) -> anyhow::Result<(Self, Book)> {
let mut courses = Courses::default();
let mut current_course_name = None;
let mut current_session_name = None;
for item in &mut book.sections {
// We only want to process chapters, omitting part titles and separators.
let BookItem::Chapter(chapter) = item else {
continue;
};
let (frontmatter, content) = split_frontmatter(chapter)?;
chapter.content = content;
// If 'course' is given, use that course (if not 'none') and reset the
// session.
if let Some(course_name) = &frontmatter.course {
current_session_name = None;
if course_name == "none" {
current_course_name = None;
} else {
current_course_name = Some(course_name.clone());
}
}
// If 'session' is given, use that session.
if let Some(session_name) = &frontmatter.session {
current_session_name = Some(session_name.clone());
}
if current_course_name.is_some() && current_session_name.is_none() {
anyhow::bail!(
"{:?}: 'session' must appear in frontmatter when 'course' appears",
chapter.path
);
}
// If we have a course and session, then add this chapter to it as a
// segment.
if let (Some(course_name), Some(session_name)) =
(&current_course_name, &current_session_name)
{
let course = courses.course_mut(course_name);
let session = course.session_mut(session_name);
session.target_minutes += frontmatter.target_minutes.unwrap_or(0);
session.add_segment(frontmatter, chapter)?;
}
}
Ok((courses, book))
}
/// Get a reference to a course, adding a new one if none by this name
/// exists.
fn course_mut(&mut self, name: impl AsRef<str>) -> &mut Course {
let name = name.as_ref();
if let Some(found_idx) =
self.courses.iter().position(|course| &course.name == name)
{
return &mut self.courses[found_idx];
}
let course = Course::new(name);
self.courses.push(course);
self.courses.last_mut().unwrap()
}
/// Find a course by name.
pub fn find_course(&self, name: impl AsRef<str>) -> Option<&Course> {
let name = name.as_ref();
self.courses.iter().find(|c| c.name == name)
}
/// Find the slide generated from the given Chapter within these courses,
/// returning the "path" to that slide.
pub fn find_slide(
&self,
chapter: &Chapter,
) -> Option<(&Course, &Session, &Segment, &Slide)> {
let Some(ref source_path) = chapter.source_path else {
return None;
};
for course in self {
for session in course {
for segment in session {
for slide in segment {
if slide.source_paths.contains(source_path) {
return Some((course, session, segment, slide));
}
}
}
}
}
return None;
}
}
impl<'a> IntoIterator for &'a Courses {
type Item = &'a Course;
type IntoIter = std::slice::Iter<'a, Course>;
fn into_iter(self) -> Self::IntoIter {
(&self.courses).into_iter()
}
}
impl Course {
fn new(name: impl Into<String>) -> Self {
Course { name: name.into(), ..Default::default() }
}
/// Get a reference to a session, adding a new one if none by this name
/// exists.
fn session_mut(&mut self, name: impl AsRef<str>) -> &mut Session {
let name = name.as_ref();
if let Some(found_idx) =
self.sessions.iter().position(|session| &session.name == name)
{
return &mut self.sessions[found_idx];
}
let session = Session::new(name);
self.sessions.push(session);
self.sessions.last_mut().unwrap()
}
/// Return the total duration of this course, as the sum of all segment
/// durations.
///
/// This includes breaks between segments, but does not count time between
/// between sessions.
pub fn minutes(&self) -> u64 {
self.into_iter().map(|s| s.minutes()).sum()
}
/// Return the target duration of this course, as the sum of all segment
/// target durations.
///
/// This includes breaks between segments, but does not count time between
/// sessions.
pub fn target_minutes(&self) -> u64 {
self.into_iter().map(|s| s.target_minutes()).sum()
}
/// Generate a Markdown schedule for this course, for placement at the given
/// path.
pub fn schedule(&self) -> String {
let mut outline = String::from("Course schedule:\n");
for session in self {
writeln!(
&mut outline,
" * {} ({}, including breaks)\n",
session.name,
duration(session.minutes())
)
.unwrap();
let mut segments = Table::new(["Segment".into(), "Duration".into()]);
for segment in session {
// Skip short segments (welcomes, wrap-up, etc.)
if segment.minutes() == 0 {
continue;
}
segments
.add_row([segment.name.clone(), duration(segment.minutes())]);
}
writeln!(&mut outline, "{}\n", segments).unwrap();
}
outline
}
}
impl<'a> IntoIterator for &'a Course {
type Item = &'a Session;
type IntoIter = std::slice::Iter<'a, Session>;
fn into_iter(self) -> Self::IntoIter {
(&self.sessions).into_iter()
}
}
impl Session {
fn new(name: impl Into<String>) -> Self {
Session { name: name.into(), ..Default::default() }
}
/// Add a new segment to the session, representing sub-items as slides.
fn add_segment(
&mut self,
frontmatter: Frontmatter,
chapter: &mut Chapter,
) -> anyhow::Result<()> {
let mut segment = Segment::new(&chapter.name);
segment.add_slide(frontmatter, chapter, false)?;
for sub_chapter in &mut chapter.sub_items {
let BookItem::Chapter(sub_chapter) = sub_chapter else {
continue;
};
let (frontmatter, content) = split_frontmatter(sub_chapter)?;
sub_chapter.content = content;
segment.add_slide(frontmatter, sub_chapter, true)?;
}
self.segments.push(segment);
Ok(())
}
/// Generate a Markdown outline for this session, for placement at the given
/// path.
pub fn outline(&self) -> String {
let mut segments = Table::new(["Segment".into(), "Duration".into()]);
for segment in self {
// Skip short segments (welcomes, wrap-up, etc.)
if segment.minutes() == 0 {
continue;
}
segments.add_row([segment.name.clone(), duration(segment.minutes())]);
}
format!(
"Including {BREAK_DURATION} minute breaks, this session should take about {}. It contains:\n\n{}",
duration(self.minutes()), segments)
}
/// Return the total duration of this session.
pub fn minutes(&self) -> u64 {
let instructional_time: u64 = self.into_iter().map(|s| s.minutes()).sum();
if instructional_time == 0 {
return instructional_time;
}
let breaks = (self.into_iter().filter(|s| s.minutes() > 0).count() - 1)
as u64
* BREAK_DURATION;
instructional_time + breaks
}
/// Return the target duration of this session.
///
/// This includes breaks between segments.
pub fn target_minutes(&self) -> u64 {
if self.target_minutes > 0 {
self.target_minutes
} else {
self.minutes()
}
}
}
impl<'a> IntoIterator for &'a Session {
type Item = &'a Segment;
type IntoIter = std::slice::Iter<'a, Segment>;
fn into_iter(self) -> Self::IntoIter {
(&self.segments).into_iter()
}
}
impl Segment {
fn new(name: impl Into<String>) -> Self {
Segment { name: name.into(), ..Default::default() }
}
/// Create a slide from a chapter. If `recurse` is true, sub-items of this
/// chapter are included in this slide as well.
fn add_slide(
&mut self,
frontmatter: Frontmatter,
chapter: &mut Chapter,
recurse: bool,
) -> anyhow::Result<()> {
let mut slide = Slide::new(frontmatter, chapter);
if recurse {
slide.add_sub_chapters(chapter)?;
}
self.slides.push(slide);
Ok(())
}
/// Return the total duration of this segment (the sum of the durations of
/// the enclosed slides).
pub fn minutes(&self) -> u64 {
self.into_iter().map(|s| s.minutes()).sum()
}
pub fn outline(&self) -> String {
let mut slides = Table::new(["Slide".into(), "Duration".into()]);
for slide in self {
if slide.minutes() == 0 {
continue;
}
slides.add_row([slide.name.clone(), duration(slide.minutes())]);
}
format!(
"This segment should take about {}. It contains:\n\n{}",
duration(self.minutes()),
slides,
)
}
}
impl<'a> IntoIterator for &'a Segment {
type Item = &'a Slide;
type IntoIter = std::slice::Iter<'a, Slide>;
fn into_iter(self) -> Self::IntoIter {
(&self.slides).into_iter()
}
}
impl Slide {
fn new(frontmatter: Frontmatter, chapter: &Chapter) -> Self {
let mut slide = Self { name: chapter.name.clone(), ..Default::default() };
slide.add_frontmatter(&frontmatter);
slide.push_source_path(&chapter.source_path);
slide
}
fn add_frontmatter(&mut self, frontmatter: &Frontmatter) {
self.minutes += frontmatter.minutes.unwrap_or(0);
}
fn push_source_path(&mut self, source_path: &Option<PathBuf>) {
if let Some(source_path) = &source_path {
self.source_paths.push(source_path.clone());
}
}
/// Add sub-chapters of this chapter to this slide (recursively).
fn add_sub_chapters(&mut self, chapter: &mut Chapter) -> anyhow::Result<()> {
for sub_slide in &mut chapter.sub_items {
let BookItem::Chapter(sub_slide) = sub_slide else {
continue;
};
let (frontmatter, content) = split_frontmatter(sub_slide)?;
sub_slide.content = content;
if frontmatter.course.is_some() || frontmatter.session.is_some() {
anyhow::bail!(
"{:?}: sub-slides may not have 'course' or 'session' set",
sub_slide.path
);
}
self.add_frontmatter(&frontmatter);
self.push_source_path(&sub_slide.source_path);
self.add_sub_chapters(sub_slide)?;
}
Ok(())
}
/// Determine whether the given chapter is a sub-chapter of this slide.
pub fn is_sub_chapter(&self, chapter: &Chapter) -> bool {
// The first `source_path` in the slide is the "parent" chapter, so anything
// else is a sub-chapter.
chapter.source_path.as_ref() != self.source_paths.get(0)
}
/// Return the total duration of this slide.
pub fn minutes(&self) -> u64 {
self.minutes
}
}