novel-cli 0.17.0

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

use clap::Args;
use color_eyre::eyre::{self, Result};
use fluent_templates::Loader;
use novel_api::{
    CiweimaoClient, CiyuanjiClient, Client, Options, SfacgClient, Tag, WordCountRange,
};
use tokio::sync::Semaphore;
use url::Url;

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

#[must_use]
#[derive(Args)]
#[command(arg_required_else_help = true,
    about = LOCALES.lookup(&LANG_ID, "search_command"))]
pub struct Search {
    #[arg(short, long,
        help = LOCALES.lookup(&LANG_ID, "source"))]
    pub source: Source,

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

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

    #[arg(help = LOCALES.lookup(&LANG_ID, "keyword"))]
    pub keyword: Option<String>,

    #[arg(long, help = LOCALES.lookup(&LANG_ID, "min_word_count"))]
    pub min_word_count: Option<u32>,

    #[arg(long, help = LOCALES.lookup(&LANG_ID, "max_word_count"))]
    pub max_word_count: Option<u32>,

    #[arg(long, help = LOCALES.lookup(&LANG_ID, "update_days"))]
    pub update_days: Option<u8>,

    #[arg(long, help = LOCALES.lookup(&LANG_ID, "is_finished"))]
    pub is_finished: Option<bool>,

    #[arg(long, help = LOCALES.lookup(&LANG_ID, "is_vip"))]
    pub is_vip: Option<bool>,

    #[arg(long, help = LOCALES.lookup(&LANG_ID, "category"))]
    pub category: Option<String>,

    #[arg(long, value_delimiter = ',',
        help = LOCALES.lookup(&LANG_ID, "tags"))]
    pub tags: Vec<String>,

    #[arg(long, value_delimiter = ',',
    help = LOCALES.lookup(&LANG_ID, "excluded_tags"))]
    pub excluded_tags: Vec<String>,

    #[arg(long, default_value_t = 10, value_parser = clap::value_parser!(u8).range(1..=100),
      help = LOCALES.lookup(&LANG_ID, "limit"))]
    pub limit: u8,

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

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

    #[arg(short, long, default_value_t = 8, value_parser = clap::value_parser!(u8).range(1..=8),
    help = LOCALES.lookup(&LANG_ID, "maximum_concurrency"))]
    pub maximum_concurrency: u8,

    #[arg(long, num_args = 0..=1, default_missing_value = super::DEFAULT_PROXY,
        help = LOCALES.lookup(&LANG_ID, "proxy"))]
    pub proxy: Option<Url>,

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

    #[arg(long, num_args = 0..=1, default_missing_value = super::default_cert_path(),
        help = super::cert_help_msg())]
    pub cert: Option<PathBuf>,
}

pub async fn execute(config: Search) -> Result<()> {
    match config.source {
        Source::Sfacg => {
            let mut client = SfacgClient::new().await?;
            super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
            do_execute(client, config).await?;
        }
        Source::Ciweimao => {
            let mut client = CiweimaoClient::new().await?;
            super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
            utils::log_in(&client, &config.source, config.ignore_keyring).await?;
            do_execute(client, config).await?;
        }
        Source::Ciyuanji => {
            let mut client = CiyuanjiClient::new().await?;
            super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
            utils::log_in_without_password(&client).await?;
            do_execute(client, config).await?;
        }
    }

    Ok(())
}

async fn do_execute<T>(client: T, config: Search) -> Result<()>
where
    T: Client + Send + Sync + 'static,
{
    let client = Arc::new(client);
    super::handle_shutdown_signal(&client);

    if config.show_categories {
        let categories = client.categories().await?;
        println!("{}", vec_to_string(categories)?);
    } else if config.show_tags {
        let tags = client.tags().await?;
        println!("{}", vec_to_string(tags)?);
    } else {
        let mut page = 0;
        let semaphore = Arc::new(Semaphore::new(config.maximum_concurrency as usize));

        let options = create_options(&client, &config).await?;
        tracing::debug!("{options:#?}");

        let mut novel_infos = Vec::new();
        loop {
            let size = u16::clamp(config.limit as u16 - novel_infos.len() as u16, 10, 50);

            let novel_ids = client.search_infos(&options, page, size).await?;
            page += 1;

            if novel_ids.is_none() {
                break;
            }

            let mut handles = Vec::new();
            for novel_id in novel_ids.unwrap() {
                let client = Arc::clone(&client);
                let permit = semaphore.clone().acquire_owned().await.unwrap();

                handles.push(tokio::spawn(async move {
                    let novel_info = utils::novel_info(&client, novel_id).await?;
                    drop(permit);
                    eyre::Ok(novel_info)
                }));
            }

            for handle in handles {
                let novel_info = handle.await??;

                if !novel_infos.contains(&novel_info) {
                    novel_infos.push(novel_info);
                }
            }

            if novel_infos.len() >= config.limit as usize {
                break;
            }
        }

        novel_infos.truncate(config.limit as usize);

        utils::print_novel_infos(novel_infos, &config.converts)?;
    }

    Ok(())
}

async fn create_options<T>(client: &Arc<T>, config: &Search) -> Result<Options>
where
    T: Client,
{
    let mut options = Options {
        keyword: config.keyword.clone(),
        is_finished: config.is_finished,
        is_vip: config.is_vip,
        update_days: config.update_days,
        ..Default::default()
    };

    if let Some(categories_name) = &config.category {
        let categories = client.categories().await?;

        match categories
            .iter()
            .find(|category| category.name == *categories_name)
        {
            Some(category) => options.category = Some(category.clone()),
            None => {
                eyre::bail!(
                    "The category was not found: `{categories_name}`, all available categories are: `{}`",
                    vec_to_string(categories)?
                );
            }
        }
    }

    if !config.tags.is_empty() {
        options.tags = Some(to_tags(client, &config.tags).await?)
    }

    if !config.excluded_tags.is_empty() {
        options.excluded_tags = Some(to_tags(client, &config.excluded_tags).await?)
    }

    if let Some(min_word_count) = config.min_word_count
        && config.max_word_count.is_none()
    {
        options.word_count = Some(WordCountRange::RangeFrom(min_word_count..));
    } else if config.min_word_count.is_none()
        && let Some(max_word_count) = config.max_word_count
    {
        options.word_count = Some(WordCountRange::RangeTo(..max_word_count));
    } else if let Some(min_word_count) = config.min_word_count
        && let Some(max_word_count) = config.max_word_count
    {
        options.word_count = Some(WordCountRange::Range(min_word_count..max_word_count));
    }

    Ok(options)
}

async fn to_tags<T>(client: &Arc<T>, tag_names: &Vec<String>) -> Result<Vec<Tag>>
where
    T: Client,
{
    let mut result = Vec::new();

    let tags = client.tags().await?;
    for tag_name in tag_names {
        match tags.iter().find(|tag| tag.name == *tag_name) {
            Some(tag) => result.push(tag.clone()),
            None => {
                eyre::bail!(
                    "The tag was not found: `{tag_name}`, all available tags are: `{}`",
                    vec_to_string(tags)?
                );
            }
        }
    }

    Ok(result)
}

fn vec_to_string<T>(vec: &[T]) -> Result<String>
where
    T: ToString,
{
    let result = vec
        .iter()
        .map(|item| item.to_string())
        .collect::<Vec<String>>()
        .join("");

    Ok(result)
}