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