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(())
}