novel-cli 0.17.0

A set of tools for downloading novels from the web, manipulating text, and generating EPUB
Documentation
use std::path::Path;

use color_eyre::eyre::Result;
use serde::Serialize;
use tokio::fs;

use crate::cmd::Convert;
use crate::utils::{self, Content, Lang, Novel, Writer};

#[must_use]
#[derive(Serialize)]
struct Config<'a> {
    book: Book<'a>,
    output: Output,
}

#[must_use]
#[derive(Serialize)]
struct Book<'a> {
    title: &'a str,
    description: Option<String>,
    authors: Vec<&'a str>,
    language: Lang,
}

#[must_use]
#[derive(Serialize)]
struct Output {
    html: Html,
}

#[must_use]
#[derive(Serialize)]
#[serde(rename_all = "kebab-case")]
struct Html {
    no_section_label: bool,
}

pub async fn generate_mdbook<T>(novel: Novel, convert: T) -> Result<()>
where
    T: AsRef<[Convert]>,
{
    assert!(!novel.name.is_empty(), "The novel name is empty");

    let output_dir_path = utils::to_novel_dir_name(&novel.name);
    if output_dir_path.is_dir() {
        tracing::warn!("The mdBook output directory already exists and will be deleted");
        utils::remove_file_or_dir(&output_dir_path)?;
    }

    fs::create_dir_all(&output_dir_path).await?;
    let base_path = dunce::canonicalize(output_dir_path)?;

    generate_book_toml(&novel, &base_path, &convert).await?;
    generate_summary(&novel, &base_path, &convert).await?;
    generate_cover(&novel, &base_path, &convert).await?;
    generate_introduction(&novel, &base_path, &convert).await?;
    generate_chapters(&novel, &base_path).await?;

    let image_path = base_path.join("src").join("images");
    super::save_image(&novel, image_path)?;

    Ok(())
}

async fn generate_book_toml<T, E>(novel: &Novel, base_path: T, convert: E) -> Result<()>
where
    T: AsRef<Path>,
    E: AsRef<[Convert]>,
{
    let config = Config {
        book: Book {
            title: novel.name.as_str(),
            description: novel.introduction.as_ref().map(|v| v.join("\n")),
            authors: vec![novel.author_name.as_str()],
            language: utils::lang(convert),
        },
        output: Output {
            html: Html {
                no_section_label: true,
            },
        },
    };

    let buf = toml::to_string(&config)?;

    let path = base_path.as_ref().join("book.toml");
    let mut writer = Writer::new(path).await?;

    writer.write(buf).await?;
    writer.flush().await?;

    Ok(())
}

async fn generate_summary<T, E>(novel: &Novel, base_path: T, convert: E) -> Result<()>
where
    T: AsRef<Path>,
    E: AsRef<[Convert]>,
{
    let path = base_path.as_ref().join("src").join("SUMMARY.md");
    let mut writer = Writer::new(path).await?;

    writer
        .writeln(format!(
            "# {}",
            utils::convert_str("目录", &convert, false)?
        ))
        .await?;
    writer.ln().await?;

    if novel.cover_image.is_some() {
        writer
            .writeln(format!(
                "- [{}](cover.md)",
                utils::convert_str("封面", &convert, false)?
            ))
            .await?;
        writer.ln().await?;
    }

    if novel.introduction.is_some() {
        writer
            .writeln(format!(
                "- [{}](introduction.md)",
                utils::convert_str("简介", &convert, false)?
            ))
            .await?;
        writer.ln().await?;
    }

    let mut volume_count = 1;
    let mut chapter_count = 1;

    for volume in &novel.volumes {
        if !volume.chapters.is_empty() {
            let volume_dir = format!("volume{}", utils::num_to_str(volume_count));
            volume_count += 1;

            writer
                .writeln(format!("- [{}]({}/README.md)", volume.title, volume_dir))
                .await?;

            for chapter in &volume.chapters {
                if chapter.contents.is_some() {
                    let chapter_file_name =
                        format!("chapter{}.md", utils::num_to_str(chapter_count));
                    chapter_count += 1;

                    writer
                        .writeln(format!(
                            "  - [{}]({}/{})",
                            chapter.title, volume_dir, chapter_file_name
                        ))
                        .await?;
                }
            }
        }
    }

    writer.flush().await?;

    Ok(())
}

async fn generate_cover<T, E>(novel: &Novel, base_path: T, convert: E) -> Result<()>
where
    T: AsRef<Path>,
    E: AsRef<[Convert]>,
{
    if let Some(ref cover_image) = novel.cover_image {
        let path = base_path.as_ref().join("src").join("cover.md");
        let mut writer = Writer::new(&path).await?;

        writer
            .writeln(format!("# {}", utils::convert_str("封面", convert, false)?))
            .await?;

        match super::cover_image_name(cover_image) {
            Ok(cover_image_name) => {
                let image_path = path.join("images").join(cover_image_name);
                let image_path = pathdiff::diff_paths(image_path, &path).unwrap();
                let image_path_str = image_path.display().to_string().replace('\\', "/");

                writer.ln().await?;
                writer
                    .writeln(&super::image_markdown_str(image_path_str))
                    .await?;
            }
            Err(err) => tracing::error!("Failed to get cover image name: {err}"),
        }

        writer.flush().await?;
    }

    Ok(())
}

async fn generate_introduction<T, E>(novel: &Novel, base_path: T, convert: E) -> Result<()>
where
    T: AsRef<Path>,
    E: AsRef<[Convert]>,
{
    if let Some(ref introduction) = novel.introduction {
        let path = base_path.as_ref().join("src").join("introduction.md");
        let mut writer = Writer::new(path).await?;

        writer
            .writeln(format!("# {}", utils::convert_str("简介", convert, false)?))
            .await?;
        writer.ln().await?;

        let mut buf = String::with_capacity(512);
        for line in introduction {
            buf.push_str(line);
            buf.push_str("\n\n");
        }
        // last '\n'
        buf.pop();

        writer.write(buf).await?;
        writer.flush().await?;
    }

    Ok(())
}

async fn generate_chapters<T>(novel: &Novel, base_path: T) -> Result<()>
where
    T: AsRef<Path>,
{
    let src_path = base_path.as_ref().join("src");
    let image_path = src_path.join("images");

    let mut volume_index = 1;
    let mut chapter_index = 1;
    let mut image_index = 1;

    for volume in &novel.volumes {
        if !volume.chapters.is_empty() {
            let volume_path = src_path.join(format!("volume{}", utils::num_to_str(volume_index)));
            volume_index += 1;

            let mut volume_writer = Writer::new(volume_path.join("README.md")).await?;
            volume_writer.writeln(format!("# {}", volume.title)).await?;
            volume_writer.flush().await?;

            for chapter in &volume.chapters {
                if let Some(contents) = &chapter.contents {
                    let chapter_path = volume_path
                        .join(format!("chapter{}", utils::num_to_str(chapter_index)))
                        .with_extension("md");
                    chapter_index += 1;

                    let mut chapter_writer = Writer::new(chapter_path).await?;

                    chapter_writer
                        .writeln(format!("# {}", chapter.title))
                        .await?;
                    chapter_writer.ln().await?;

                    let mut buf = String::with_capacity(8192);
                    for content in contents {
                        match content {
                            Content::Text(line) => {
                                buf.push_str(line);
                                buf.push_str("\n\n");
                            }
                            Content::Image(image) => {
                                match super::new_image_name(image, image_index) {
                                    Ok(image_name) => {
                                        image_index += 1;

                                        let image_path = image_path.join(image_name);
                                        let image_path =
                                            pathdiff::diff_paths(image_path, &volume_path).unwrap();
                                        let image_path_str =
                                            image_path.display().to_string().replace('\\', "/");

                                        buf.push_str(&super::image_markdown_str(image_path_str));
                                        buf.push_str("\n\n");
                                    }
                                    Err(err) => tracing::error!("Failed to get image name: {err}"),
                                }
                            }
                        }
                    }

                    // last '\n'
                    buf.pop();
                    chapter_writer.write(buf).await?;

                    chapter_writer.flush().await?;
                }
            }
        }
    }

    Ok(())
}