ordinary-modify 0.6.0

Project manipulation tool for Ordinary
Documentation
use anyhow::{Result, bail};
use fs_err::{File, OpenOptions, create_dir_all, read_dir, remove_file};
use ordinary_types::{ContentField, ContentObject};
use regex::RegexBuilder;
use serde_json::Value;
use std::io::{Seek, SeekFrom, Write};
use std::path::Path;
use std::{env, io::Read};
use yaml_rust2::YamlLoader;

fn main() -> Result<()> {
    let args: Vec<String> = env::args().collect();

    if let Some(operation) = args.get(1) {
        let operation = operation.as_str();

        match operation {
            "before_build" => {
                if let Some(posts_dir) = args.get(2) {
                    let posts_dir = Path::new(posts_dir);
                    let mut posts = vec![];

                    if posts_dir.exists() {
                        for entry in read_dir(posts_dir)? {
                            let entry = entry?;
                            let path = entry.path();

                            let mut file = File::open(&path)?;

                            let mut md = String::new();
                            file.read_to_string(&mut md)?;

                            let post = extract_post(&md)?;

                            let Some(slug) = path
                                .file_name()
                                .and_then(|s| s.to_str())
                                .and_then(|s| s.strip_suffix(".md"))
                            else {
                                bail!("bad slug");
                            };

                            posts.push(ContentObject {
                                uuid: post.uuid,
                                instance_of: "post".to_string(),
                                fields: vec![
                                    ContentField {
                                        name: "slug".to_string(),
                                        value: Value::String(slug.to_string()),
                                    },
                                    ContentField {
                                        name: "title".to_string(),
                                        value: Value::String(post.title),
                                    },
                                    ContentField {
                                        name: "date".to_string(),
                                        value: Value::Number(post.date.into()),
                                    },
                                    ContentField {
                                        name: "body".to_string(),
                                        value: Value::String(post.body),
                                    },
                                ],
                            });
                        }
                    }

                    let mut content_file = OpenOptions::new()
                        .read(true)
                        .write(true)
                        .open("content.json")?;

                    let mut initial_content_json = String::new();
                    content_file.read_to_string(&mut initial_content_json)?;

                    let initial_content: Vec<ContentObject> =
                        serde_json::from_str(&initial_content_json)?;

                    let mut filtered_content = initial_content
                        .iter()
                        .filter(|obj| obj.instance_of != "post")
                        .cloned()
                        .collect::<Vec<ContentObject>>();

                    filtered_content.extend(posts);
                    let updated_content = serde_json::to_string_pretty(&filtered_content)?;

                    content_file.seek(SeekFrom::Start(0))?;
                    content_file.write_all(updated_content.as_bytes())?;
                    content_file.flush()?;
                }
            }
            "after_add" | "after_edit" | "after_delete" => {
                if let Some(posts_dir) = args.get(2)
                    && let Some(post_json) = args.get(3)
                {
                    let posts_dir = Path::new(posts_dir);
                    if !posts_dir.exists() {
                        create_dir_all(posts_dir)?;
                    }

                    let post: ContentObject = serde_json::from_str(post_json)?;
                    let (slug, md) = to_slug_and_md(&post)?;

                    let mut post_path = posts_dir.join(slug);
                    post_path.add_extension("md");

                    match operation {
                        "after_add" => {
                            let mut post_file = File::create(&post_path)?;
                            post_file.write_all(md.as_bytes())?;
                            post_file.flush()?;
                        }
                        "after_edit" => {
                            for entry in read_dir(posts_dir)? {
                                let entry = entry?;
                                let mut file = File::open(entry.path())?;

                                let mut curr_md = String::new();
                                file.read_to_string(&mut curr_md)?;

                                if extract_post(&curr_md)?.uuid == post.uuid {
                                    remove_file(entry.path())?;

                                    let mut post_file = File::create(&post_path)?;
                                    post_file.write_all(md.as_bytes())?;
                                    post_file.flush()?;

                                    break;
                                }
                            }
                        }
                        "after_delete" => {
                            remove_file(&post_path)?;
                        }
                        _ => unreachable!(),
                    }
                } else {
                    bail!("no content object arg provided")
                }
            }
            _ => bail!("invalid operation: {operation}"),
        }
    } else {
        bail!("no args provided")
    }

    Ok(())
}

fn to_slug_and_md(post: &ContentObject) -> Result<(String, String)> {
    let mut slug = String::new();
    let mut title = String::new();
    let mut date = 0i64;
    let mut body = String::new();

    for field in &post.fields {
        match field.name.as_str() {
            "slug" => {
                if let Some(value) = field.value.as_str() {
                    slug = value.to_string();
                }
            }
            "title" => {
                if let Some(value) = field.value.as_str() {
                    title = value.to_string();
                }
            }
            "date" => {
                if let Some(value) = field.value.as_i64() {
                    date = value
                }
            }
            "body" => {
                if let Some(value) = field.value.as_str() {
                    body = value.to_string();
                }
            }
            _ => bail!("invalid field name"),
        }
    }

    let md = format!(
        "---
uuid: {}
title: {title}
date: {date}
---

{body}
",
        post.uuid
    );

    Ok((slug, md))
}

struct Post {
    uuid: String,
    title: String,
    date: i64,
    body: String,
}

fn extract_post(md: &str) -> anyhow::Result<Post> {
    let re = RegexBuilder::new(r"^---$(?<frontmatter>[\s\S]*?)^---$")
        .unicode(true)
        .multi_line(true)
        .build()?;

    let Some(caps) = re.captures(md) else {
        bail!("no frontmatter");
    };

    let frontmatter = &caps["frontmatter"];

    let Some(body) = md.strip_prefix(&format!("---{frontmatter}---\n\n")) else {
        bail!("newline needs to exist between frontmatter and body");
    };

    let yaml = &YamlLoader::load_from_str(frontmatter)?[0];

    if let Some(uuid) = yaml["uuid"].as_str()
        && let Some(title) = yaml["title"].as_str()
        && let Some(date) = yaml["date"].as_i64()
    {
        Ok(Post {
            uuid: uuid.to_string(),
            title: title.to_string(),
            date,
            body: body.to_string(),
        })
    } else {
        bail!("bad frontmatter");
    }
}