novel-cli 0.17.0

A set of tools for downloading novels from the web, manipulating text, and generating EPUB
Documentation
pub mod bookshelf;
pub mod build;
pub mod check;
pub mod completions;
pub mod download;
pub mod epub;
pub mod info;
pub mod read;
pub mod real_cugan;
pub mod search;
pub mod sign;
pub mod template;
pub mod transform;
pub mod unzip;
pub mod update;
pub mod zip;

use std::collections::HashMap;
use std::fs::File;
use std::path::Path;
use std::sync::Arc;
use std::{cmp, env, process};

use clap::ValueEnum;
use color_eyre::eyre::Result;
use fluent_templates::Loader;
use fluent_templates::fluent_bundle::FluentValue;
use novel_api::{Client, NovelInfo, VolumeInfos};
use ratatui::buffer::Buffer;
use ratatui::layout::{Rect, Size};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Text};
use ratatui::widgets::{
    Block, Paragraph, Scrollbar, ScrollbarOrientation, StatefulWidget, StatefulWidgetRef, Widget,
    Wrap,
};
use strum::AsRefStr;
use tokio::signal;
use tui_tree_widget::{Tree, TreeItem, TreeState};
use tui_widgets::scrollview::{ScrollView, ScrollViewState};
use url::Url;

use crate::{LANG_ID, LOCALES, utils};

const DEFAULT_PROXY: &str = "http://127.0.0.1:8080";

#[must_use]
#[derive(Clone, PartialEq, ValueEnum, AsRefStr)]
pub enum Source {
    #[strum(serialize = "sfacg")]
    Sfacg,
    #[strum(serialize = "ciweimao")]
    Ciweimao,
    #[strum(serialize = "ciyuanji")]
    Ciyuanji,
}

#[must_use]
#[derive(Clone, PartialEq, ValueEnum, AsRefStr)]
pub enum Format {
    Pandoc,
    Mdbook,
}

#[must_use]
#[derive(Clone, PartialEq, ValueEnum, AsRefStr)]
pub enum Convert {
    S2T,
    T2S,
    JP2T2S,
    CUSTOM,
}

#[inline]
#[must_use]
fn default_cert_path() -> String {
    novel_api::home_dir_path()
        .unwrap()
        .join(".mitmproxy")
        .join("mitmproxy-ca-cert.pem")
        .display()
        .to_string()
}

fn set_options<T, E>(client: &mut T, proxy: &Option<Url>, no_proxy: &bool, cert: &Option<E>)
where
    T: Client,
    E: AsRef<Path>,
{
    if let Some(proxy) = proxy {
        client.proxy(proxy.clone());
    }

    if *no_proxy {
        client.no_proxy();
    }

    if let Some(cert) = cert {
        client.cert(cert.as_ref().to_path_buf())
    }
}

fn handle_shutdown_signal<T>(client: &Arc<T>)
where
    T: Client + Send + Sync + 'static,
{
    let client = Arc::clone(client);

    tokio::spawn(async move {
        shutdown_signal().await;

        tracing::warn!("Download terminated, login data will be saved");

        client.shutdown().await.unwrap();
        process::exit(128 + libc::SIGINT);
    });
}

async fn shutdown_signal() {
    let ctrl_c = async {
        signal::ctrl_c()
            .await
            .expect("failed to install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("failed to install signal handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
}

fn cert_help_msg() -> String {
    let args = {
        let mut map = HashMap::new();
        map.insert(
            "cert_path".into(),
            FluentValue::String(default_cert_path().into()),
        );
        map
    };

    LOCALES.lookup_with_args(&LANG_ID, "cert", &args)
}

#[derive(Default, PartialEq)]
enum Mode {
    #[default]
    Running,
    Quit,
}

pub struct ScrollableParagraph {
    title: Option<Line<'static>>,
    text: Text<'static>,
}

impl ScrollableParagraph {
    pub fn new<T>(text: T) -> Self
    where
        T: Into<Text<'static>>,
    {
        ScrollableParagraph {
            title: None,
            text: text.into(),
        }
    }

    pub fn title<T>(self, title: T) -> Self
    where
        T: Into<Line<'static>>,
    {
        ScrollableParagraph {
            title: Some(title.into()),
            ..self
        }
    }
}

impl StatefulWidgetRef for ScrollableParagraph {
    type State = ScrollViewState;

    fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        let mut block = Block::bordered();
        if let Some(title) = &self.title {
            block = block.title(title.clone());
        }

        let paragraph = Paragraph::new(self.text.clone()).wrap(Wrap { trim: false });
        let mut scroll_view = ScrollView::new(Size::new(area.width - 1, area.height));
        let mut block_area = block.inner(scroll_view.buf().area);

        let scroll_height = cmp::max(
            paragraph.line_count(block_area.width) as u16
                + (scroll_view.buf().area.height - block_area.height),
            area.height,
        );

        let scroll_width = if area.height >= scroll_height {
            // 不需要滚动条
            area.width
        } else {
            area.width - 1
        };

        scroll_view = ScrollView::new(Size::new(scroll_width, scroll_height));

        let scroll_view_buf = scroll_view.buf_mut();
        block_area = block.inner(scroll_view_buf.area);

        Widget::render(block, scroll_view_buf.area, scroll_view_buf);
        Widget::render(paragraph, block_area, scroll_view_buf);
        StatefulWidget::render(scroll_view, area, buf, state);
    }
}

struct ChapterList {
    novel_name: String,
    items: Vec<TreeItem<'static, u32>>,
}

impl ChapterList {
    fn build(
        novel_info: &NovelInfo,
        volume_infos: &VolumeInfos,
        converts: &[Convert],
        has_cover: bool,
        has_introduction: bool,
    ) -> Result<Self> {
        let mut items = Vec::with_capacity(4);

        if has_cover {
            items.push(TreeItem::new(
                0,
                utils::convert_str("封面", converts, true)?,
                vec![],
            )?);
        }

        if has_introduction {
            items.push(TreeItem::new(
                1,
                utils::convert_str("简介", converts, true)?,
                vec![],
            )?);
        }

        for volume_info in volume_infos {
            let mut chapters = Vec::with_capacity(32);
            for chapter in &volume_info.chapter_infos {
                if chapter.is_valid() {
                    let mut title_prefix = "";
                    if chapter.payment_required() {
                        title_prefix = "【未订阅】";
                    }

                    chapters.push(TreeItem::new_leaf(
                        chapter.id,
                        utils::convert_str(
                            format!("{title_prefix}{}", chapter.title),
                            converts,
                            true,
                        )?,
                    ));
                }
            }

            if !chapters.is_empty() {
                items.push(TreeItem::new(
                    volume_info.id,
                    utils::convert_str(&volume_info.title, converts, true)?,
                    chapters,
                )?);
            }
        }

        Ok(Self {
            novel_name: utils::convert_str(&novel_info.name, converts, true)?,
            items,
        })
    }
}

impl StatefulWidgetRef for ChapterList {
    type State = TreeState<u32>;

    fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        let widget = Tree::new(&self.items)
            .unwrap()
            .block(Block::bordered().title(self.novel_name.clone()))
            .experimental_scrollbar(Some(
                Scrollbar::new(ScrollbarOrientation::VerticalRight)
                    .begin_symbol(None)
                    .track_symbol(None)
                    .end_symbol(None),
            ))
            .highlight_style(
                Style::new()
                    .fg(Color::Black)
                    .bg(Color::LightGreen)
                    .add_modifier(Modifier::BOLD),
            );

        StatefulWidget::render(widget, area, buf, state);
    }
}

fn unzip<T>(path: T) -> Result<()>
where
    T: AsRef<Path>,
{
    let output_dir = env::current_dir()?.join(path.as_ref().file_stem().unwrap());
    if output_dir.try_exists()? {
        tracing::warn!("The epub output directory already exists and will be deleted");
        utils::remove_file_or_dir(&output_dir)?;
    }

    let file = File::open(path)?;

    novel_api::unzip(file, output_dir)?;

    Ok(())
}