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