studium/src/core/controller/course_files.rs
Antoine Pelletier 5e160ae11c wip
2026-09-29 09:47:27 +02:00

290 lines
9.9 KiB
Rust

//! Files of a course. For now only the lecture notes: markdown written in the app,
//! rendered to pdf with the course template, and tagged `notes`.
//!
//! Both files of a note live in the folder of the course, named after the note:
//! `Cours/CS-477 Advanced OS/Notes/Lecture 1 Notes - Intro.md` and `.pdf`. They are
//! moved when the note is renamed. The `.md` holds the front matter expected by the
//! template, so it can also be rendered by hand with `mdpdf`. The editor only sees what
//! comes after it.
use thiserror::Error;
use tracing::error;
use crate::core::{
controller::{ControllerError, courses::file_name},
models::{
course::{Course, CourseId},
course_file::{CourseFile, CourseFileId, FileKind, LectureNoteContent, SavedLectureNote},
tag::NOTES_TAG,
},
repositories::{RepositoryError, pdf_renderer::RenderError},
};
/// Values of the front matter that do not depend on the note
#[derive(Debug, Clone, Default)]
pub struct NotesSettings {
pub student_name: Option<String>,
pub student_id: Option<String>,
/// Absolute path of the logo
pub logo: Option<String>,
}
/// Sub folder of the course holding the notes
const NOTES_FOLDER: &str = "Notes";
fn markdown_path(course: &Course, path: &str) -> String {
format!("{}/{path}.md", course.folder)
}
fn pdf_path(course: &Course, path: &str) -> String {
format!("{}/{path}.pdf", course.folder)
}
/// Path of a note in the folder of its course, without extension
fn note_path(title: &str) -> String {
format!("{NOTES_FOLDER}/{}", file_name(title))
}
impl super::AppController {
pub async fn get_course_files(
&self,
course_id: CourseId,
) -> Result<Vec<CourseFile>, ControllerError> {
// 404 rather than an empty list for a course that does not exist
self.db.get_course(course_id).await?;
self.db
.get_course_files(course_id)
.await
.map_err(Into::into)
}
/// `NotFound` if the file does not exist or is not a note
async fn get_note(&self, id: CourseFileId) -> Result<CourseFile, ControllerError> {
let file = self.db.get_course_file(id).await?;
if file.kind != FileKind::Note {
return Err(RepositoryError::NotFound(format!("lecture note {id}")).into());
}
Ok(file)
}
pub async fn get_lecture_note(
&self,
id: CourseFileId,
) -> Result<LectureNoteContent, ControllerError> {
let note = self.get_note(id).await?;
let course = self.db.get_course(note.course_id).await?;
let content = match self
.storage
.read_file(&markdown_path(&course, &note.path))
.await
{
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
// Lost file: the note can still be rewritten
Err(RepositoryError::NotFound(_)) => String::new(),
Err(err) => return Err(err.into()),
};
Ok(LectureNoteContent {
file: note,
content: strip_front_matter(&content).to_owned(),
})
}
/// `NotFound` if the note does not exist or its last rendering failed
pub async fn get_lecture_note_pdf(&self, id: CourseFileId) -> Result<Vec<u8>, ControllerError> {
let note = self.get_note(id).await?;
let course = self.db.get_course(note.course_id).await?;
self.storage
.read_file(&pdf_path(&course, &note.path))
.await
.map_err(Into::into)
}
pub async fn create_lecture_note(
&self,
course_id: CourseId,
title: String,
content: String,
) -> Result<SavedLectureNote, ControllerError> {
let title = validate_title(title)?;
let course = self.db.get_course(course_id).await?;
let path = note_path(&title);
self.check_path_free(course_id, &path, None).await?;
let notes_tag = self.db.get_tag_id(NOTES_TAG).await?;
let note = self
.db
.create_lecture_note(course_id, title.clone(), path, &[notes_tag])
.await?;
self.save_lecture_note(&course, note, title, content).await
}
pub async fn update_lecture_note(
&self,
id: CourseFileId,
title: String,
content: String,
) -> Result<SavedLectureNote, ControllerError> {
let title = validate_title(title)?;
let note = self.get_note(id).await?;
let course = self.db.get_course(note.course_id).await?;
// Renamed: its files follow
let path = note_path(&title);
if path != note.path {
self.check_path_free(course.id, &path, Some(id)).await?;
for (from, to) in [
(
markdown_path(&course, &note.path),
markdown_path(&course, &path),
),
(pdf_path(&course, &note.path), pdf_path(&course, &path)),
] {
match self.storage.rename(&from, &to).await {
Ok(()) | Err(RepositoryError::NotFound(_)) => {}
Err(err) => return Err(err.into()),
}
}
}
let note = CourseFile { path, ..note };
self.save_lecture_note(&course, note, title, content).await
}
pub async fn delete_lecture_note(&self, id: CourseFileId) -> Result<(), ControllerError> {
let note = self.get_note(id).await?;
let course = self.db.get_course(note.course_id).await?;
self.db.delete_course_file(id).await?;
self.storage
.delete_file(&markdown_path(&course, &note.path))
.await?;
self.storage
.delete_file(&pdf_path(&course, &note.path))
.await?;
Ok(())
}
/// `NameTaken` if another file of the course (than `except`) already has this path
async fn check_path_free(
&self,
course_id: CourseId,
path: &str,
except: Option<CourseFileId>,
) -> Result<(), ControllerError> {
let files = self.db.get_course_files(course_id).await?;
if files
.iter()
.any(|file| file.path == path && Some(file.id) != except)
{
return Err(LectureNotesControllerError::NameTaken.into());
}
Ok(())
}
/// Writes the markdown, then renders it. The markdown is kept even when the
/// rendering fails, and the error is returned to be shown in the editor.
async fn save_lecture_note(
&self,
course: &Course,
note: CourseFile,
title: String,
content: String,
) -> Result<SavedLectureNote, ControllerError> {
let markdown = format!("{}{}", self.front_matter(course, &title), content);
let pdf = pdf_path(course, &note.path);
self.storage
.write_file(&markdown_path(course, &note.path), markdown.as_bytes())
.await?;
let pdf_error = match self.renderer.render(&markdown).await {
Ok(content) => {
self.storage.write_file(&pdf, &content).await?;
None
}
Err(err) => {
// No stale pdf that would not match the markdown anymore
self.storage.delete_file(&pdf).await?;
Some(match err {
RenderError::Failed(log) => log,
RenderError::Internal(message) => {
error!("Unable to render lecture note {}: {message}", note.id);
message
}
})
}
};
let note = self
.db
.update_lecture_note(note.id, title, note.path, pdf_error.is_none())
.await?;
Ok(SavedLectureNote { note, pdf_error })
}
/// Front matter read by `template.tex`, e.g. `course: "CS-477 -- Advanced OS"`
fn front_matter(&self, course: &Course, title: &str) -> String {
let course_title = match &course.code {
Some(code) => format!("{code} -- {}", course.name),
None => course.name.clone(),
};
let fields = [
("student-name", self.notes.student_name.as_deref()),
("student-id", self.notes.student_id.as_deref()),
("logo", self.notes.logo.as_deref()),
("course", Some(course_title.as_str())),
("subtitle", Some(title)),
];
let mut front_matter = String::from("---\n");
for (key, value) in fields {
if let Some(value) = value {
front_matter.push_str(&format!("{key}: {}\n", yaml_string(value)));
}
}
front_matter.push_str("---\n\n");
front_matter
}
}
/// A json string is a valid double quoted yaml scalar: quotes and backslashes escaped
fn yaml_string(value: &str) -> String {
serde_json::to_string(value).unwrap_or_default()
}
/// Content of a markdown file without its front matter
fn strip_front_matter(markdown: &str) -> &str {
markdown
.strip_prefix("---\n")
.and_then(|rest| rest.split_once("\n---\n"))
.map(|(_, body)| body.trim_start_matches('\n'))
.unwrap_or(markdown)
}
fn validate_title(title: String) -> Result<String, ControllerError> {
let title = title.trim().to_owned();
if title.is_empty() {
return Err(LectureNotesControllerError::EmptyTitle.into());
}
Ok(title)
}
#[derive(Error, Debug)]
pub enum LectureNotesControllerError {
#[error("The title of the note is empty")]
EmptyTitle,
#[error("Another file of the course already has this name")]
NameTaken,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_front_matter() {
let markdown = "---\ncourse: \"X\"\nsubtitle: \"Y\"\n---\n\n# Intro\n---\nText";
assert_eq!(strip_front_matter(markdown), "# Intro\n---\nText");
assert_eq!(strip_front_matter("# No front matter"), "# No front matter");
}
#[test]
fn escapes_yaml() {
assert_eq!(yaml_string(r#"Notes: "memory""#), r#""Notes: \"memory\"""#);
}
}