290 lines
9.9 KiB
Rust
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, ¬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<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, ¬e.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, ¬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<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, ¬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<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\"""#);
|
|
}
|
|
}
|