txlib 0.2.1

text based epub library
use chrono::{Datelike, Utc};
use epub::doc::EpubDoc;
use glob::glob;
use rayon::prelude::*;
use std::fs::File;
use std::io::{BufRead, BufReader, Write};
use std::path::Path;

const CHARS_PER_PAGE: usize = 2000; // Chars per page for counting pages

pub struct Book {
    timestamp: u32,
    read: bool,
    title: String,
    author: String,
    series: String,
    pages: usize,
    path: String,
}

impl Book {
    fn read_symbol(&self) -> String {
        if self.read {
            String::from("*")
        } else {
            String::from(" ")
        }
    }
}

pub enum SortBy {
    Date,
    Read,
    Title,
    Author,
    Pages,
    Series,
}

// LOAD LIBRARY //

/// Returns library data structure after joining saved DB and epub file list.
pub fn load_library(lib_db_path: &Path, epub_path: &Path, sort_by: &SortBy, reverse: bool) -> Result<Vec<Book>, &'static str> {
    let epub_list = find_epub_files(epub_path);
    let mut library = read_library_db(lib_db_path)?;

    // Check whether new epub files are available and add them to the library accordingly
    let mut new_books = epub_list
        .par_iter()
        .filter(|e| !library.iter().any(|b| &b.path == *e))
        .map(|e| create_book_from_epub(e))
        .collect::<Vec<Book>>();

    library.append(&mut new_books);

    // Remove unavailable epub files from library DB
    library.retain(|e| epub_list.contains(&e.path));

    sort_library(&mut library, sort_by, reverse);
    Ok(library)
}

/// Returns a vector with the paths to all epub files in provided root path.
fn find_epub_files(epub_path: &Path) -> Vec<String> {
    let glob_pattern = format!("{}{}", epub_path.to_str().unwrap(), "/**/*.epub");
    glob(&glob_pattern)
        .unwrap()
        .filter(Result::is_ok)
        .map(|p| p.unwrap().display().to_string())
        .collect()
}

/// Reads csv input DB and returns vector with respective Book data structure.
fn read_library_db(lib_file_path: &Path) -> Result<Vec<Book>, &'static str> {
    if !lib_file_path.exists() {
        return Ok(Vec::new());
    }
    let Ok(file) = File::open(lib_file_path) else {
        return Err("error: unable to read library DB")
    };
    let library = BufReader::new(file)
        .lines()
        .skip(1) // Skip header line
        .filter(|l| l.is_ok() && !(l.as_ref().unwrap().is_empty() || l.as_ref().unwrap().starts_with('#')))
        .map(|l| line_to_book(&l.unwrap()))
        .collect();
    Ok(library)
}

/// Returns a Book struct from the csv line string.
fn line_to_book(line: &str) -> Book {
    let fields: Vec<&str> = line
        .split(',')
        .map(str::trim)
        .collect();

    Book {
        timestamp: fields[0].parse().unwrap_or(999_999),
        read: !fields[1].trim().is_empty(),
        title: fields[2].to_string(),
        author: fields[3].to_string(),
        pages: fields[4].parse().unwrap_or(0),
        series: fields[5].to_string(),
        path: fields[6].to_string(),
    }
}

/// Returns Book data structure from data of the provided epub file path.
fn create_book_from_epub(epub_path: &str) -> Book {
    let epub_doc = EpubDoc::new(epub_path);

    let (title, author, pages) = epub_doc.map_or_else(|_| (
        String::from("Unknown title"),
        String::from("Unknown author"),
        0,
    ), |mut doc| (
        doc.mdata("title").unwrap_or_else(|| String::from("Unknown title")),
        doc.mdata("creator").unwrap_or_else(|| String::from("Unknown author")),
        count_epub_pages(&mut doc),
    ));

    Book {
        timestamp: create_timestamp(&epub_path),
        read: false,
        title,
        author,
        series: String::new(),
        pages,
        path: epub_path.to_string(),
    }
}

/// Returns page count in provided epub file based on `CHARS_PER_PAGE` constant.
fn count_epub_pages(epub_doc: &mut EpubDoc<BufReader<File>>) -> usize {
    let char_count = epub_doc.spine.clone().iter().fold(0_usize, |acc, r| {
        acc + epub_doc
            .get_resource_str(r)
            .unwrap_or((String::new(), String::new()))
            .0
            .chars()
            .filter(|s| *s != '\n')
            .count()
    });
    char_count / CHARS_PER_PAGE
}

/// Returns today as a timestamp with YYMMDD format.
fn create_timestamp(path: &str) -> u32 {
    let filename = path.split('/').last().unwrap();
    if filename[0..6].parse::<u32>().is_ok() {
        return filename[0..6].parse::<u32>().unwrap();
    }

    let now = Utc::now();
    let date_str = format!("{:02}{:02}{:02}", now.year(), now.month(), now.day());
    (date_str[2..]).parse().unwrap_or(999_999)
}

/// Sorts library based on provided Book field.
fn sort_library(library: &mut [Book], sort_by: &SortBy, reverse: bool) {
    match sort_by {
        SortBy::Date => library.par_sort_unstable_by_key(|b| b.timestamp),
        SortBy::Read => library.par_sort_unstable_by_key(|b| b.read),
        SortBy::Title => library.par_sort_unstable_by(|b1, b2| b1.title.cmp(&b2.title)),
        SortBy::Author => library.par_sort_unstable_by(|b1, b2| b1.author.cmp(&b2.author)),
        SortBy::Pages => library.par_sort_unstable_by_key(|b| b.pages),
        SortBy::Series => library.par_sort_unstable_by(|b1, b2| b1.series.cmp(&b2.series)),
    }
    if reverse {
        library.reverse();
    }
}

// WRITE LIBRARY //

/// Write library data structure to stdout and/or to file with appropriate formatting.
pub fn write_library(library_string: &str, output_path: &Path) -> Result<(), &'static str> {
    let Ok(mut output_file) = File::create(output_path) else {
        return Err("error: unable to open library DB")
    };
    if write!(output_file, "{library_string}").is_err() {
        return Err("error: unable to write library to DB");
    }
    Ok(())
}

/// Returns a csv string from library data structure.
pub fn library_to_string(library: &[Book]) -> String {
    let mut lib_str = String::new();

    // Create and add header line
    let col_text = [
        String::from("DATE"),
        String::from("R"),
        String::from("TITLE"),
        String::from("AUTHOR"),
        String::from("PG"),
        String::from("SERIES"),
        String::from("PATH"),
    ];
    lib_str.push_str(string_to_csv(&col_text).as_str());

    // Create and add a csv string from each book in the library
    lib_str.push_str(
        library
            .iter()
            .map(book_to_line)
            .collect::<String>()
            .as_str(),
    );

    lib_str.trim_end().to_string()
}

/// Returns a csv string from provided Book struct.
fn book_to_line(book: &Book) -> String {
    let col_text = [
        book.timestamp.to_string(),
        book.read_symbol(),
        book.title.clone(),
        book.author.clone(),
        book.pages.to_string(),
        book.series.clone(),
        book.path.clone(),
    ];
    string_to_csv(&col_text)
}

/// Iterates through each book field and returns its contents as a csv string.
fn string_to_csv(col_text: &[String]) -> String {
    let mut tab_str = col_text
        .par_iter()
        .map(|s| s.replace(',', ""))
        .collect::<Vec<_>>()
        .join(",");
    tab_str.push('\n');
    tab_str
}