bookyard-core 0.1.0

Core configuration and catalog model for Bookyard.
Documentation
use serde::{Deserialize, Serialize};

use crate::BookConfig;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Catalog {
    pub title: String,
    pub books: Vec<CatalogBook>,
    pub folders: Vec<FolderNode>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatalogBook {
    pub id: String,
    pub title: String,
    pub source: String,
    pub href: String,
    pub folders: Vec<String>,
    pub tags: Vec<String>,
    pub order: i32,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FolderNode {
    pub name: String,
    pub path: String,
    pub books: Vec<String>,
    pub children: Vec<FolderNode>,
}

pub fn build_catalog(config: &crate::BookyardConfig) -> Catalog {
    let mut books: Vec<_> = config
        .books
        .iter()
        .map(|book| CatalogBook {
            id: book.id.clone(),
            title: book.title.clone(),
            source: book.source.clone(),
            href: format!("books/{}/index.html", book.id),
            folders: book.folders.clone(),
            tags: book.tags.clone(),
            order: book.order,
        })
        .collect();
    books.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.title.cmp(&b.title)));
    Catalog {
        title: config.workspace.title.clone(),
        folders: build_folder_tree(&config.books),
        books,
    }
}

pub fn build_folder_tree(books: &[BookConfig]) -> Vec<FolderNode> {
    let mut roots: Vec<FolderNode> = Vec::new();
    for book in books {
        for folder in &book.folders {
            insert_folder_path(&mut roots, folder, &book.id);
        }
    }
    sort_nodes(&mut roots);
    roots
}

fn insert_folder_path(nodes: &mut Vec<FolderNode>, folder: &str, book_id: &str) {
    let mut parts = Vec::new();
    for raw in folder.split(['/', '\\']) {
        let part = raw.trim();
        if !part.is_empty() {
            parts.push(part);
        }
    }
    if parts.is_empty() {
        return;
    }
    insert_parts(nodes, &parts, String::new(), book_id);
}

fn insert_parts(nodes: &mut Vec<FolderNode>, parts: &[&str], parent: String, book_id: &str) {
    let name = parts[0].to_string();
    let path = if parent.is_empty() {
        name.clone()
    } else {
        format!("{parent}/{name}")
    };
    let index = match nodes.iter().position(|node| node.name == name) {
        Some(index) => index,
        None => {
            nodes.push(FolderNode {
                name,
                path: path.clone(),
                books: Vec::new(),
                children: Vec::new(),
            });
            nodes.len() - 1
        }
    };
    if parts.len() == 1 {
        if !nodes[index].books.iter().any(|id| id == book_id) {
            nodes[index].books.push(book_id.to_owned());
        }
        return;
    }
    insert_parts(&mut nodes[index].children, &parts[1..], path, book_id);
}

fn sort_nodes(nodes: &mut [FolderNode]) {
    nodes.sort_by(|a, b| a.path.cmp(&b.path));
    for node in nodes {
        node.books.sort();
        sort_nodes(&mut node.children);
    }
}