aboutsummaryrefslogblamecommitdiffstats
path: root/src/posts/mod.rs
blob: c75dd539faf45aaa3cbdb5f3ea73d4607ab14954 (plain) (tree)
1
2
3
4
5
6
7
8
9

            
                    
 

                              
                             
                            
                                                                               



                                                          
 


                                         




               

                             
               
                            

                                      
                      
                        
                           


            
                          






                                                      



                                                                                 
                                                                







                                                                           

                                                       

                   

 


                                                             


                                                                                  
                                         

                                                                      

                                                               

                                 

                                                  


                 









                                                                          
                                                           





                                                 































                                                                   

 
              
                   
                                             

 
                           

                    
                 








































                                                                             



                                        
                                       


                                         




                                                   

                                                                   





















                                                                        
                                        




                                                                      
                         



                          
mod article;
mod note;
pub mod syndication;

use std::collections::HashSet;

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use markdown::{mdast::Node, CompileOptions, Constructs, Options, ParseOptions};
use rocket::http::Status;
use serde::{ser::SerializeStruct, Deserialize, Serialize};
use tokio::fs;
use tokio::io::AsyncReadExt;

use crate::{error::BlossomError, Result};

#[derive(Serialize, Debug)]
enum PostType {
    Article,
    Note,
}

#[derive(Debug)]
pub struct Post<D: Content> {
    id: String,
    subject: Option<String>,
    created_at: DateTime<Utc>,
    updated_at: Option<DateTime<Utc>>,
    tags: Vec<String>,
    post_type: PostType,
    render: Option<String>,
    data: D,
}

impl<D: Content> Post<D> {
    pub async fn render(&mut self) -> Result<()> {
        self.render = Some(self.data.render().await?);
        Ok(())
    }
}

impl<D: Content + Serialize> Serialize for Post<D> {
    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        let mut state = serializer.serialize_struct("Post", 7)?;
        state.serialize_field("subject", &self.subject)?;
        state.serialize_field("created_at", &self.created_at.to_string())?;
        state.serialize_field(
            "updated_at",
            &self.updated_at.and_then(|time| Some(time.to_string())),
        )?;
        state.serialize_field("tags", &self.tags)?;
        state.serialize_field("post_type", &self.post_type)?;
        state.serialize_field("render", &self.render)?;
        state.serialize_field("data", &self.data)?;
        state.end()
    }
}

pub async fn get_blogposts() -> Result<Vec<Post<Article>>> {
    let mut blogposts: Vec<Post<Article>> = Vec::new();
    let mut articles_dir = fs::read_dir("./articles").await?;
    while let Some(file) = articles_dir.next_entry().await? {
        let name = file.file_name();
        let name = name.to_str().unwrap_or_default()[..name.len() - 3].to_owned();
        let blogpost: Article = Article {
            path: file.path().to_str().unwrap_or_default().to_owned(),
            name,
        };
        let blogpost = Post::try_from(blogpost).await.unwrap();
        blogposts.push(blogpost);
    }
    blogposts.sort_by_key(|post| post.created_at);
    blogposts.reverse();
    Ok(blogposts)
}

pub async fn get_blogpost(post_name: &str) -> Result<Post<Article>> {
    let mut articles_dir = fs::read_dir("./articles").await?;
    while let Some(file) = articles_dir.next_entry().await? {
        let name = file.file_name();
        let name = &name.to_str().unwrap_or_default()[..name.len() - 3];
        if name == post_name {
            let blogpost = Article {
                path: file.path().to_str().unwrap_or_default().to_owned(),
                name: name.to_owned(),
            };
            let blogpost = Post::try_from(blogpost).await?;
            return Ok(blogpost);
        }
    }
    Err(BlossomError::NotFound(Status::new(404)))
}

pub fn get_tags<D: Content>(posts: &Vec<Post<D>>) -> Vec<&String> {
    let mut tags = posts
        .into_iter()
        .fold(HashSet::new(), |mut acc, post| {
            let tags = &post.tags;
            for tag in tags {
                acc.insert(tag);
            }
            acc
        })
        .into_iter()
        .collect::<Vec<_>>();
    tags.sort();
    tags
}

pub fn filter_by_tags<'p, D: Content>(
    posts: Vec<Post<D>>,
    filter_tags: &HashSet<String>,
) -> Vec<Post<D>> {
    posts
        .into_iter()
        .filter(|post| {
            for tag in &post.tags {
                match filter_tags.contains(tag) {
                    true => return true,
                    false => continue,
                }
            }
            false
        })
        .collect()
}

#[async_trait]
pub trait Content {
    async fn render(&self) -> Result<String>;
}

#[derive(Serialize, Debug)]
pub struct Article {
    path: String,
    name: String,
}

impl Article {
    async fn tree(&self) -> Result<Node> {
        let mut file = fs::File::open(&self.path).await?;
        let mut buf = String::new();
        file.read_to_string(&mut buf).await?;
        Ok(markdown::to_mdast(
            &buf,
            &ParseOptions {
                constructs: Constructs {
                    frontmatter: true,
                    ..Constructs::default()
                },
                ..ParseOptions::default()
            },
        )
        .unwrap())
    }

    async fn frontmatter(&self) -> Result<String> {
        let tree = self.tree().await?;
        let children = tree.children();
        if let Some(children) = children {
            if let Some(toml) = children.into_iter().find_map(|el| match el {
                Node::Toml(toml) => Some(toml.value.to_owned()),
                _ => None,
            }) {
                return Ok(toml);
            };
        }
        Err(BlossomError::NoMetadata(Status::new(500)))
    }
}

#[async_trait]
impl Content for Article {
    async fn render(&self) -> Result<String> {
        let mut file = fs::File::open(&self.path).await?;
        let mut buf = String::new();
        file.read_to_string(&mut buf).await?;
        let options = Options {
            parse: ParseOptions {
                constructs: Constructs {
                    frontmatter: true,
                    ..Constructs::gfm()
                },
                ..ParseOptions::default()
            },
            compile: CompileOptions {
                gfm_task_list_item_checkable: true,
                allow_dangerous_html: true,
                ..CompileOptions::default()
            },
        };
        Ok(markdown::to_html_with_options(&buf, &options).unwrap())
    }
}

#[derive(Deserialize)]
struct ArticleMetadata {
    title: String,
    created_at: String,
    updated_at: Option<String>,
    tags: Vec<String>,
}

impl Post<Article> {
    async fn try_from(article: Article) -> Result<Post<Article>> {
        let metadata = article.frontmatter().await?;
        let metadata: ArticleMetadata = toml::from_str(&metadata)?;
        let updated_at = if let Some(updated_at) = metadata.updated_at {
            Some(updated_at.parse::<DateTime<Utc>>()?)
        } else {
            None
        };

        Ok(Post {
            id: article.name.to_owned(),
            subject: Some(metadata.title),
            created_at: metadata.created_at.parse::<DateTime<Utc>>()?,
            updated_at,
            tags: metadata.tags,
            post_type: PostType::Article,
            render: None,
            data: article,
        })
    }
}