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