novel-cli 0.17.0

A set of tools for downloading novels from the web, manipulating text, and generating EPUB
Documentation
use std::fmt::Write;
use std::fs;
use std::path::{Path, PathBuf};

use clap::Args;
use color_eyre::eyre::{self, Result};
use fluent_templates::Loader;
use pulldown_cmark::{Event, MetadataBlockKind, Options, Parser, Tag, TagEnd, TextMergeWithOffset};
use walkdir::WalkDir;

use crate::cmd::Convert;
use crate::utils::{self, Metadata};
use crate::{LANG_ID, LOCALES};

#[must_use]
#[derive(Args)]
#[command(arg_required_else_help = true,
    about = LOCALES.lookup(&LANG_ID, "transform_command"))]
pub struct Transform {
    #[arg(help = LOCALES.lookup(&LANG_ID, "file_path"))]
    pub file_path: PathBuf,

    #[arg(short, long, value_enum, value_delimiter = ',',
        help = LOCALES.lookup(&LANG_ID, "converts"))]
    pub converts: Vec<Convert>,

    #[arg(short, long, default_value_t = false,
        help = LOCALES.lookup(&LANG_ID, "delete"))]
    pub delete: bool,
}

pub fn execute(config: Transform) -> Result<()> {
    let input_file_path;
    let input_file_parent_path;

    if utils::is_markdown_or_txt_file(&config.file_path)? {
        input_file_path = dunce::canonicalize(&config.file_path)?;
        input_file_parent_path = input_file_path.parent().unwrap().to_path_buf();
    } else if let Ok(Some(path)) =
        utils::try_get_markdown_or_txt_file_name_in_dir(&config.file_path)
    {
        input_file_path = path;
        input_file_parent_path = dunce::canonicalize(&config.file_path)?;
    } else {
        eyre::bail!("Invalid input path: `{}`", config.file_path.display());
    }
    tracing::info!("Input file path: `{}`", input_file_path.display());

    let input_file_stem = input_file_path.file_stem().unwrap().to_str().unwrap();
    let input_file_ext = input_file_path.extension().unwrap().to_str().unwrap();

    let bytes = fs::read(&input_file_path)?;
    let markdown = simdutf8::basic::from_utf8(&bytes)?;
    let mut parser = TextMergeWithOffset::new(
        Parser::new_ext(markdown, Options::ENABLE_YAML_STYLE_METADATA_BLOCKS).into_offset_iter(),
    );

    let mut metadata = utils::get_metadata(&mut parser)?;
    convert_metadata(&mut metadata, &config.converts, &input_file_parent_path)?;

    let mut image_index = 1;
    let mut in_heading = false;
    let parser = parser.map(|(event, range)| match event {
        Event::Start(Tag::CodeBlock(_)) | Event::End(TagEnd::CodeBlock) => {
            panic!("Cannot contain CodeBlock: {}", &markdown[range]);
        }
        Event::Start(Tag::Heading {
            level,
            id,
            classes,
            attrs,
        }) => {
            in_heading = true;
            Event::Start(Tag::Heading {
                level,
                id,
                classes,
                attrs,
            })
        }
        Event::End(TagEnd::Heading(level)) => {
            in_heading = false;
            Event::End(TagEnd::Heading(level))
        }
        Event::Text(text) => Event::Text(
            utils::convert_str(text, &config.converts, in_heading)
                .unwrap()
                .into(),
        ),
        Event::Start(Tag::Image {
            link_type,
            dest_url,
            title,
            id,
        }) => {
            let new_image_path =
                utils::convert_image_ext(input_file_parent_path.join(dest_url.as_ref())).unwrap();

            let new_image_path =
                utils::convert_image_file_stem(new_image_path, utils::num_to_str(image_index))
                    .unwrap();
            image_index += 1;

            Event::Start(Tag::Image {
                link_type,
                dest_url: new_image_path
                    .file_name()
                    .unwrap()
                    .to_str()
                    .unwrap()
                    .to_string()
                    .into(),
                title,
                id,
            })
        }
        _ => event,
    });

    let metadata_block = [
        Event::Start(Tag::MetadataBlock(MetadataBlockKind::YamlStyle)),
        Event::Text(serde_saphyr::to_string(&metadata)?.into()),
        Event::End(TagEnd::MetadataBlock(MetadataBlockKind::YamlStyle)),
    ];

    let mut buf = String::with_capacity(markdown.len());
    pulldown_cmark_to_cmark::cmark(metadata_block.iter(), &mut buf)?;
    buf.write_char('\n')?;
    pulldown_cmark_to_cmark::cmark(parser, &mut buf)?;
    buf.write_char('\n')?;

    if config.delete {
        utils::remove_file_or_dir(&input_file_path)?;
    } else {
        let backup_file_path =
            input_file_parent_path.join(format!("{input_file_stem}.old.{input_file_ext}"));
        tracing::info!("Backup file path: `{}`", backup_file_path.display());

        fs::rename(&input_file_path, backup_file_path)?;
    }

    let new_file_name = utils::to_novel_dir_name(utils::convert_str(
        &metadata.title,
        &config.converts,
        false,
    )?)
    .with_extension(input_file_ext);
    let output_file_path = input_file_parent_path.join(new_file_name);
    tracing::info!("Output file path: `{}`", output_file_path.display());

    if cfg!(windows) {
        buf = buf.replace('\n', "\r\n");
    }
    fs::write(&output_file_path, buf)?;

    if config.delete {
        let image_paths = utils::read_markdown_to_images(&output_file_path)?;

        let mut to_remove = Vec::new();
        for entry in WalkDir::new(&input_file_parent_path).max_depth(1) {
            let path = entry?.path().to_path_buf();

            if path != output_file_path && path != input_file_parent_path {
                let file_name = path.file_name().unwrap().to_str().unwrap();
                if !image_paths.contains(&PathBuf::from(file_name)) {
                    to_remove.push(path);
                }
            }
        }

        utils::remove_file_or_dir_all(&to_remove)?;
    }

    Ok(())
}

fn convert_metadata(metadata: &mut Metadata, converts: &[Convert], input_dir: &Path) -> Result<()> {
    metadata.title = utils::convert_str(&metadata.title, converts, false)?;
    metadata.author = utils::convert_str(&metadata.author, converts, false)?;
    metadata.lang = utils::lang(converts);

    if let Some(description) = &metadata.description {
        let mut v = Vec::with_capacity(4);

        for line in description.split('\n') {
            v.push(utils::convert_str(line, converts, false).unwrap());
        }

        metadata.description = Some(v.join("\n"));
    }

    if let Some(cover_image) = &metadata.cover_image {
        let new_image_path = utils::convert_image_ext(input_dir.join(cover_image)).unwrap();
        let new_image_path = utils::convert_image_file_stem(new_image_path, "cover").unwrap();

        metadata.cover_image = Some(PathBuf::from(
            new_image_path.file_name().unwrap().to_str().unwrap(),
        ));
    }

    Ok(())
}