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;
32use ratatui::buffer::Buffer;
33use ratatui::layout::{Rect, Size};
34use ratatui::text::Text;
35use ratatui::widgets::block::Title;
36use ratatui::widgets::{Block, Paragraph, StatefulWidget, Widget, Wrap};
37use strum::AsRefStr;
38use tokio::signal;
39use tui_widgets::scrollview::{ScrollView, ScrollViewState};
40use url::Url;
41use walkdir::DirEntry;
42
43use crate::{LANG_ID, LOCALES, utils};
44
45const DEFAULT_PROXY: &str = "http://127.0.0.1:8080";
46
47const DEFAULT_PROXY_SURGE: &str = "http://127.0.0.1:6152";
48
49#[must_use]
50#[derive(Clone, PartialEq, ValueEnum, AsRefStr)]
51pub enum Source {
52    #[strum(serialize = "sfacg")]
53    Sfacg,
54    #[strum(serialize = "ciweimao")]
55    Ciweimao,
56    #[strum(serialize = "ciyuanji")]
57    Ciyuanji,
58}
59
60#[must_use]
61#[derive(Clone, PartialEq, ValueEnum, AsRefStr)]
62pub enum Format {
63    Pandoc,
64    Mdbook,
65}
66
67#[must_use]
68#[derive(Clone, PartialEq, ValueEnum, AsRefStr)]
69pub enum Convert {
70    S2T,
71    T2S,
72    JP2T2S,
73    CUSTOM,
74}
75
76#[inline]
77#[must_use]
78fn default_cert_path() -> String {
79    novel_api::home_dir_path()
80        .unwrap()
81        .join(".mitmproxy")
82        .join("mitmproxy-ca-cert.pem")
83        .display()
84        .to_string()
85}
86
87fn set_options<T, E>(client: &mut T, proxy: &Option<Url>, no_proxy: &bool, cert: &Option<E>)
88where
89    T: Client,
90    E: AsRef<Path>,
91{
92    if let Some(proxy) = proxy {
93        client.proxy(proxy.clone());
94    }
95
96    if *no_proxy {
97        client.no_proxy();
98    }
99
100    if let Some(cert) = cert {
101        client.cert(cert.as_ref().to_path_buf())
102    }
103}
104
105fn handle_ctrl_c<T>(client: &Arc<T>)
106where
107    T: Client + Send + Sync + 'static,
108{
109    let client = Arc::clone(client);
110
111    tokio::spawn(async move {
112        signal::ctrl_c().await.unwrap();
113
114        tracing::warn!("Download terminated, login data will be saved");
115
116        client.shutdown().await.unwrap();
117        process::exit(128 + libc::SIGINT);
118    });
119}
120
121fn cert_help_msg() -> String {
122    let args = {
123        let mut map = HashMap::new();
124        map.insert(
125            "cert_path".into(),
126            FluentValue::String(default_cert_path().into()),
127        );
128        map
129    };
130
131    LOCALES.lookup_with_args(&LANG_ID, "cert", &args)
132}
133
134#[derive(Default, PartialEq)]
135enum Mode {
136    #[default]
137    Running,
138    Quit,
139}
140
141pub struct ScrollableParagraph<'a> {
142    title: Option<Title<'a>>,
143    text: Text<'a>,
144}
145
146impl<'a> ScrollableParagraph<'a> {
147    pub fn new<T>(text: T) -> Self
148    where
149        T: Into<Text<'a>>,
150    {
151        ScrollableParagraph {
152            title: None,
153            text: text.into(),
154        }
155    }
156
157    pub fn title<T>(self, title: T) -> Self
158    where
159        T: Into<Title<'a>>,
160    {
161        ScrollableParagraph {
162            title: Some(title.into()),
163            ..self
164        }
165    }
166}
167
168impl StatefulWidget for ScrollableParagraph<'_> {
169    type State = ScrollViewState;
170
171    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
172        let mut block = Block::bordered();
173        if self.title.is_some() {
174            block = block.title(self.title.as_ref().unwrap().clone());
175        }
176
177        let paragraph = Paragraph::new(self.text.clone()).wrap(Wrap { trim: false });
178        let mut scroll_view = ScrollView::new(Size::new(area.width - 1, area.height));
179        let mut block_area = block.inner(scroll_view.buf().area);
180
181        let scroll_height = cmp::max(
182            paragraph.line_count(block_area.width) as u16
183                + (scroll_view.buf().area.height - block_area.height),
184            area.height,
185        );
186
187        let scroll_width = if area.height >= scroll_height {
188            // 不需要滚动条
189            area.width
190        } else {
191            area.width - 1
192        };
193
194        scroll_view = ScrollView::new(Size::new(scroll_width, scroll_height));
195
196        let scroll_view_buf = scroll_view.buf_mut();
197        block_area = block.inner(scroll_view_buf.area);
198
199        Widget::render(block, scroll_view_buf.area, scroll_view_buf);
200        Widget::render(paragraph, block_area, scroll_view_buf);
201        StatefulWidget::render(scroll_view, area, buf, state);
202    }
203}
204
205fn unzip<T>(path: T) -> Result<()>
206where
207    T: AsRef<Path>,
208{
209    let path = path.as_ref();
210
211    let output_dir = env::current_dir()?.join(path.file_stem().unwrap());
212    if output_dir.try_exists()? {
213        tracing::warn!("The epub output directory already exists and will be deleted");
214        utils::remove_file_or_dir(&output_dir)?;
215    }
216
217    let file = File::open(path)?;
218    let mut archive = ZipArchive::new(file)?;
219
220    for i in 0..archive.len() {
221        let mut file = archive.by_index(i)?;
222        let outpath = match file.enclosed_name() {
223            Some(path) => path.to_owned(),
224            None => continue,
225        };
226        let outpath = output_dir.join(outpath);
227
228        if (*file.name()).ends_with('/') {
229            fs::create_dir_all(&outpath)?;
230        } else {
231            if let Some(p) = outpath.parent()
232                && !p.try_exists()?
233            {
234                fs::create_dir_all(p)?;
235            }
236            let mut outfile = fs::File::create(&outpath)?;
237            io::copy(&mut file, &mut outfile)?;
238        }
239
240        #[cfg(unix)]
241        {
242            use std::fs::Permissions;
243            use std::os::unix::fs::PermissionsExt;
244
245            if let Some(mode) = file.unix_mode() {
246                fs::set_permissions(&outpath, Permissions::from_mode(mode))?;
247            }
248        }
249    }
250
251    Ok(())
252}
253
254fn zip_dir<T, E>(iter: &mut dyn Iterator<Item = DirEntry>, prefix: T, writer: E) -> Result<()>
255where
256    T: AsRef<Path>,
257    E: Write + Seek,
258{
259    let mut zip = ZipWriter::new(writer);
260    let options = SimpleFileOptions::default()
261        .compression_method(CompressionMethod::Deflated)
262        .compression_level(Some(9));
263
264    let mut buffer = Vec::new();
265    for entry in iter {
266        let path = entry.path();
267        let name = path.strip_prefix(prefix.as_ref())?;
268
269        if path.is_file() {
270            zip.start_file(name.to_str().unwrap(), options)?;
271            let mut f = File::open(path)?;
272
273            f.read_to_end(&mut buffer)?;
274            zip.write_all(&buffer)?;
275            buffer.clear();
276        } else if !name.as_os_str().is_empty() {
277            zip.add_directory(name.to_str().unwrap(), options)?;
278        }
279    }
280    zip.finish()?;
281
282    Ok(())
283}