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