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