Skip to main content

novel_cli/cmd/
mod.rs

1pub mod bookshelf;
2pub mod build;
3pub mod check;
4pub mod completions;
5pub mod download;
6pub mod epub;
7pub mod info;
8pub mod read;
9pub mod real_cugan;
10pub mod search;
11pub mod sign;
12pub mod template;
13pub mod transform;
14pub mod unzip;
15pub mod update;
16pub mod zip;
17
18use std::collections::HashMap;
19use std::fs::File;
20use std::path::Path;
21use std::sync::Arc;
22use std::{cmp, env, process};
23
24use clap::ValueEnum;
25use color_eyre::eyre::Result;
26use fluent_templates::Loader;
27use fluent_templates::fluent_bundle::FluentValue;
28use novel_api::{Client, NovelInfo, VolumeInfos};
29use ratatui::buffer::Buffer;
30use ratatui::layout::{Rect, Size};
31use ratatui::style::{Color, Modifier, Style};
32use ratatui::text::{Line, Text};
33use ratatui::widgets::{
34    Block, Paragraph, Scrollbar, ScrollbarOrientation, StatefulWidget, StatefulWidgetRef, Widget,
35    Wrap,
36};
37use strum::AsRefStr;
38use tokio::signal;
39use tui_tree_widget::{Tree, TreeItem, TreeState};
40use tui_widgets::scrollview::{ScrollView, ScrollViewState};
41use url::Url;
42
43use crate::{LANG_ID, LOCALES, utils};
44
45const DEFAULT_PROXY: &str = "http://127.0.0.1:8080";
46
47#[must_use]
48#[derive(Clone, PartialEq, ValueEnum, AsRefStr)]
49pub enum Source {
50    #[strum(serialize = "sfacg")]
51    Sfacg,
52    #[strum(serialize = "ciweimao")]
53    Ciweimao,
54    #[strum(serialize = "ciyuanji")]
55    Ciyuanji,
56}
57
58#[must_use]
59#[derive(Clone, PartialEq, ValueEnum, AsRefStr)]
60pub enum Format {
61    Pandoc,
62    Mdbook,
63}
64
65#[must_use]
66#[derive(Clone, PartialEq, ValueEnum, AsRefStr)]
67pub enum Convert {
68    S2T,
69    T2S,
70    JP2T2S,
71    CUSTOM,
72}
73
74#[inline]
75#[must_use]
76fn default_cert_path() -> String {
77    novel_api::home_dir_path()
78        .unwrap()
79        .join(".mitmproxy")
80        .join("mitmproxy-ca-cert.pem")
81        .display()
82        .to_string()
83}
84
85fn set_options<T, E>(client: &mut T, proxy: &Option<Url>, no_proxy: &bool, cert: &Option<E>)
86where
87    T: Client,
88    E: AsRef<Path>,
89{
90    if let Some(proxy) = proxy {
91        client.proxy(proxy.clone());
92    }
93
94    if *no_proxy {
95        client.no_proxy();
96    }
97
98    if let Some(cert) = cert {
99        client.cert(cert.as_ref().to_path_buf())
100    }
101}
102
103fn handle_shutdown_signal<T>(client: &Arc<T>)
104where
105    T: Client + Send + Sync + 'static,
106{
107    let client = Arc::clone(client);
108
109    tokio::spawn(async move {
110        shutdown_signal().await;
111
112        tracing::warn!("Download terminated, login data will be saved");
113
114        client.shutdown().await.unwrap();
115        process::exit(128 + libc::SIGINT);
116    });
117}
118
119async fn shutdown_signal() {
120    let ctrl_c = async {
121        signal::ctrl_c()
122            .await
123            .expect("failed to install Ctrl+C handler");
124    };
125
126    #[cfg(unix)]
127    let terminate = async {
128        signal::unix::signal(signal::unix::SignalKind::terminate())
129            .expect("failed to install signal handler")
130            .recv()
131            .await;
132    };
133
134    #[cfg(not(unix))]
135    let terminate = std::future::pending::<()>();
136
137    tokio::select! {
138        _ = ctrl_c => {},
139        _ = terminate => {},
140    }
141}
142
143fn cert_help_msg() -> String {
144    let args = {
145        let mut map = HashMap::new();
146        map.insert(
147            "cert_path".into(),
148            FluentValue::String(default_cert_path().into()),
149        );
150        map
151    };
152
153    LOCALES.lookup_with_args(&LANG_ID, "cert", &args)
154}
155
156#[derive(Default, PartialEq)]
157enum Mode {
158    #[default]
159    Running,
160    Quit,
161}
162
163pub struct ScrollableParagraph {
164    title: Option<Line<'static>>,
165    text: Text<'static>,
166}
167
168impl ScrollableParagraph {
169    pub fn new<T>(text: T) -> Self
170    where
171        T: Into<Text<'static>>,
172    {
173        ScrollableParagraph {
174            title: None,
175            text: text.into(),
176        }
177    }
178
179    pub fn title<T>(self, title: T) -> Self
180    where
181        T: Into<Line<'static>>,
182    {
183        ScrollableParagraph {
184            title: Some(title.into()),
185            ..self
186        }
187    }
188}
189
190impl StatefulWidgetRef for ScrollableParagraph {
191    type State = ScrollViewState;
192
193    fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
194        let mut block = Block::bordered();
195        if let Some(title) = &self.title {
196            block = block.title(title.clone());
197        }
198
199        let paragraph = Paragraph::new(self.text.clone()).wrap(Wrap { trim: false });
200        let mut scroll_view = ScrollView::new(Size::new(area.width - 1, area.height));
201        let mut block_area = block.inner(scroll_view.buf().area);
202
203        let scroll_height = cmp::max(
204            paragraph.line_count(block_area.width) as u16
205                + (scroll_view.buf().area.height - block_area.height),
206            area.height,
207        );
208
209        let scroll_width = if area.height >= scroll_height {
210            // 不需要滚动条
211            area.width
212        } else {
213            area.width - 1
214        };
215
216        scroll_view = ScrollView::new(Size::new(scroll_width, scroll_height));
217
218        let scroll_view_buf = scroll_view.buf_mut();
219        block_area = block.inner(scroll_view_buf.area);
220
221        Widget::render(block, scroll_view_buf.area, scroll_view_buf);
222        Widget::render(paragraph, block_area, scroll_view_buf);
223        StatefulWidget::render(scroll_view, area, buf, state);
224    }
225}
226
227struct ChapterList {
228    novel_name: String,
229    items: Vec<TreeItem<'static, u32>>,
230}
231
232impl ChapterList {
233    fn build(
234        novel_info: &NovelInfo,
235        volume_infos: &VolumeInfos,
236        converts: &[Convert],
237        has_cover: bool,
238        has_introduction: bool,
239    ) -> Result<Self> {
240        let mut items = Vec::with_capacity(4);
241
242        if has_cover {
243            items.push(TreeItem::new(
244                0,
245                utils::convert_str("封面", converts, true)?,
246                vec![],
247            )?);
248        }
249
250        if has_introduction {
251            items.push(TreeItem::new(
252                1,
253                utils::convert_str("简介", converts, true)?,
254                vec![],
255            )?);
256        }
257
258        for volume_info in volume_infos {
259            let mut chapters = Vec::with_capacity(32);
260            for chapter in &volume_info.chapter_infos {
261                if chapter.is_valid() {
262                    let mut title_prefix = "";
263                    if chapter.payment_required() {
264                        title_prefix = "【未订阅】";
265                    }
266
267                    chapters.push(TreeItem::new_leaf(
268                        chapter.id,
269                        utils::convert_str(
270                            format!("{title_prefix}{}", chapter.title),
271                            converts,
272                            true,
273                        )?,
274                    ));
275                }
276            }
277
278            if !chapters.is_empty() {
279                items.push(TreeItem::new(
280                    volume_info.id,
281                    utils::convert_str(&volume_info.title, converts, true)?,
282                    chapters,
283                )?);
284            }
285        }
286
287        Ok(Self {
288            novel_name: utils::convert_str(&novel_info.name, converts, true)?,
289            items,
290        })
291    }
292}
293
294impl StatefulWidgetRef for ChapterList {
295    type State = TreeState<u32>;
296
297    fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
298        let widget = Tree::new(&self.items)
299            .unwrap()
300            .block(Block::bordered().title(self.novel_name.clone()))
301            .experimental_scrollbar(Some(
302                Scrollbar::new(ScrollbarOrientation::VerticalRight)
303                    .begin_symbol(None)
304                    .track_symbol(None)
305                    .end_symbol(None),
306            ))
307            .highlight_style(
308                Style::new()
309                    .fg(Color::Black)
310                    .bg(Color::LightGreen)
311                    .add_modifier(Modifier::BOLD),
312            );
313
314        StatefulWidget::render(widget, area, buf, state);
315    }
316}
317
318fn unzip<T>(path: T) -> Result<()>
319where
320    T: AsRef<Path>,
321{
322    let output_dir = env::current_dir()?.join(path.as_ref().file_stem().unwrap());
323    if output_dir.try_exists()? {
324        tracing::warn!("The epub output directory already exists and will be deleted");
325        utils::remove_file_or_dir(&output_dir)?;
326    }
327
328    let file = File::open(path)?;
329
330    novel_api::unzip(file, output_dir)?;
331
332    Ok(())
333}