//! 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, pub student_id: Option, /// Absolute path of the logo pub logo: Option, } /// 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, 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 { 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 { 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, ¬e.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, 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, ¬e.path)) .await .map_err(Into::into) } pub async fn create_lecture_note( &self, course_id: CourseId, title: String, content: String, ) -> Result { 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 { 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, ¬e.path), markdown_path(&course, &path), ), (pdf_path(&course, ¬e.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, ¬e.path)) .await?; self.storage .delete_file(&pdf_path(&course, ¬e.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, ) -> 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 { let markdown = format!("{}{}", self.front_matter(course, &title), content); let pdf = pdf_path(course, ¬e.path); self.storage .write_file(&markdown_path(course, ¬e.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 { 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\"""#); } }