1use std::{env, path::PathBuf, sync::Arc, time::Duration};
2
3use clap::Args;
4use color_eyre::eyre::{self, Result};
5use fluent_templates::Loader;
6use novel_api::{
7 ChapterInfo, CiweimaoClient, CiyuanjiClient, Client, ContentInfo, NovelInfo, SfacgClient,
8 VolumeInfos,
9};
10use ratatui::{
11 Frame,
12 buffer::Buffer,
13 crossterm::event::{
14 self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
15 MouseEventKind,
16 },
17 layout::{Constraint, Direction, Layout, Position, Rect},
18 style::{Color, Modifier, Style},
19 text::Text,
20 widgets::{Block, Clear, Scrollbar, ScrollbarOrientation, StatefulWidget, Widget},
21};
22use tokio::{runtime::Handle, task};
23use tui_tree_widget::{Tree, TreeItem, TreeState};
24use tui_widgets::{popup::Popup, scrollview::ScrollViewState};
25use url::Url;
26
27use super::{Mode, ScrollableParagraph};
28use crate::{
29 LANG_ID, LOCALES, Tui,
30 cmd::{Convert, Source},
31 utils,
32};
33
34#[must_use]
35#[derive(Args)]
36#[command(arg_required_else_help = true,
37 about = LOCALES.lookup(&LANG_ID, "read_command"))]
38pub struct Read {
39 #[arg(help = LOCALES.lookup(&LANG_ID, "novel_id"))]
40 pub novel_id: u32,
41
42 #[arg(short, long,
43 help = LOCALES.lookup(&LANG_ID, "source"))]
44 pub source: Source,
45
46 #[arg(short, long, value_enum, value_delimiter = ',',
47 help = LOCALES.lookup(&LANG_ID, "converts"))]
48 pub converts: Vec<Convert>,
49
50 #[arg(long, default_value_t = false,
51 help = LOCALES.lookup(&LANG_ID, "force_update_novel_db"))]
52 pub force_update_novel_db: bool,
53
54 #[arg(long, default_value_t = false,
55 help = LOCALES.lookup(&LANG_ID, "ignore_keyring"))]
56 pub ignore_keyring: bool,
57
58 #[arg(long, num_args = 0..=1, default_missing_value = super::DEFAULT_PROXY,
59 help = LOCALES.lookup(&LANG_ID, "proxy"))]
60 pub proxy: Option<Url>,
61
62 #[arg(long, default_value_t = false,
63 help = LOCALES.lookup(&LANG_ID, "no_proxy"))]
64 pub no_proxy: bool,
65
66 #[arg(long, num_args = 0..=1, default_missing_value = super::default_cert_path(),
67 help = super::cert_help_msg())]
68 pub cert: Option<PathBuf>,
69}
70
71pub async fn execute(config: Read) -> Result<()> {
72 match config.source {
73 Source::Sfacg => {
74 let mut client = SfacgClient::new().await?;
75 super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
76 utils::log_in(&client, &config.source, config.ignore_keyring).await?;
77 do_execute(client, config).await?;
78 }
79 Source::Ciweimao => {
80 let mut client = CiweimaoClient::new().await?;
81 super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
82 utils::log_in(&client, &config.source, config.ignore_keyring).await?;
83 do_execute(client, config).await?;
84 }
85 Source::Ciyuanji => {
86 let mut client = CiyuanjiClient::new().await?;
87 super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
88 utils::log_in_without_password(&client).await?;
89 do_execute(client, config).await?;
90 }
91 }
92
93 Ok(())
94}
95
96async fn do_execute<T>(client: T, config: Read) -> Result<()>
97where
98 T: Client + Send + Sync + 'static,
99{
100 let client = Arc::new(client);
101 super::handle_ctrl_c(&client);
102
103 if config.force_update_novel_db {
104 unsafe {
105 env::set_var("FORCE_UPDATE_NOVEL_DB", "true");
106 }
107 }
108
109 let mut terminal = crate::init_terminal()?;
110 App::new(client, config).await?.run(&mut terminal)?;
111 crate::restore_terminal()?;
112
113 Ok(())
114}
115
116struct App<T> {
117 mode: Mode,
118 percentage: u16,
119
120 chapter_list: ChapterList,
121 content_state: ScrollViewState,
122 show_subscription: bool,
123
124 chapter_list_area: Rect,
125 content_area: Rect,
126
127 config: Read,
128 client: Arc<T>,
129
130 money: u32,
131 novel_info: NovelInfo,
132 volume_infos: VolumeInfos,
133}
134
135impl<T> App<T>
136where
137 T: Client + Send + Sync + 'static,
138{
139 pub async fn new(client: Arc<T>, config: Read) -> Result<Self> {
140 let money = client.money().await?;
141 let novel_info = utils::novel_info(&client, config.novel_id).await?;
142
143 let Some(volume_infos) = client.volume_infos(config.novel_id).await? else {
144 eyre::bail!("Unable to get chapter information");
145 };
146
147 let chapter_list = ChapterList::new(&volume_infos, &config.converts)?;
148
149 Ok(App {
150 mode: Mode::default(),
151 percentage: 30,
152 chapter_list,
153 content_state: ScrollViewState::default(),
154 chapter_list_area: Rect::default(),
155 show_subscription: false,
156 content_area: Rect::default(),
157 config,
158 client,
159 money,
160 novel_info,
161 volume_infos,
162 })
163 }
164
165 fn run(&mut self, terminal: &mut Tui) -> Result<()> {
166 self.draw(terminal)?;
167
168 while self.is_running() {
169 if self.handle_events()? {
170 self.draw(terminal)?;
171 }
172 }
173
174 Ok(())
175 }
176
177 fn is_running(&self) -> bool {
178 self.mode != Mode::Quit
179 }
180
181 fn draw(&mut self, terminal: &mut Tui) -> Result<()> {
182 terminal.draw(|frame| self.render_frame(frame))?;
183 Ok(())
184 }
185
186 fn render_frame(&mut self, frame: &mut Frame) {
187 frame.render_widget(self, frame.area());
188 }
189
190 fn handle_events(&mut self) -> Result<bool> {
191 if event::poll(Duration::from_secs_f64(1.0 / 60.0))? {
192 return match event::read()? {
193 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
194 self.handle_key_event(key_event)
195 }
196 Event::Mouse(mouse_event) => Ok(self.handle_mouse_event(mouse_event)),
197 _ => Ok(false),
198 };
199 }
200 Ok(false)
201 }
202
203 fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<bool> {
204 let result = match key_event.code {
205 KeyCode::Char('q') | KeyCode::Esc => self.exit(),
206 KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
207 self.exit()
208 }
209 KeyCode::Down => {
210 if key_event.modifiers.contains(KeyModifiers::SHIFT) {
211 self.content_state.scroll_down();
212 true
213 } else {
214 self.content_state.scroll_to_top();
215 self.chapter_list.state.key_down()
216 }
217 }
218 KeyCode::Up => {
219 if key_event.modifiers.contains(KeyModifiers::SHIFT) {
220 self.content_state.scroll_up();
221 true
222 } else {
223 self.content_state.scroll_to_top();
224 self.chapter_list.state.key_up()
225 }
226 }
227 KeyCode::Right => {
228 if key_event.modifiers.contains(KeyModifiers::SHIFT) {
229 self.increase()
230 } else {
231 self.chapter_list.state.key_right()
232 }
233 }
234 KeyCode::Left => {
235 if key_event.modifiers.contains(KeyModifiers::SHIFT) {
236 self.reduce()
237 } else {
238 self.chapter_list.state.key_left()
239 }
240 }
241 KeyCode::Char('y') if self.show_subscription => {
242 self.buy_chapter()?;
243 self.show_subscription = false;
244 true
245 }
246 _ => false,
247 };
248
249 Ok(result)
250 }
251
252 fn handle_mouse_event(&mut self, mouse_event: MouseEvent) -> bool {
253 let pos = Position::new(mouse_event.column, mouse_event.row);
254
255 match mouse_event.kind {
256 MouseEventKind::ScrollDown => {
257 if self.chapter_list_area.contains(pos) {
258 self.chapter_list.state.scroll_down(1)
259 } else if self.content_area.contains(pos) {
260 self.content_state.scroll_down();
261 true
262 } else {
263 false
264 }
265 }
266 MouseEventKind::ScrollUp => {
267 if self.chapter_list_area.contains(pos) {
268 self.chapter_list.state.scroll_up(1)
269 } else if self.content_area.contains(pos) {
270 self.content_state.scroll_up();
271 true
272 } else {
273 false
274 }
275 }
276 MouseEventKind::Down(MouseButton::Left) => {
277 if self.chapter_list.state.click_at(pos) {
278 self.content_state.scroll_to_top();
279 true
280 } else {
281 false
282 }
283 }
284 _ => false,
285 }
286 }
287
288 fn exit(&mut self) -> bool {
289 self.mode = Mode::Quit;
290 false
291 }
292
293 fn increase(&mut self) -> bool {
294 if self.percentage <= 45 {
295 self.percentage += 5;
296 return true;
297 }
298 false
299 }
300
301 fn reduce(&mut self) -> bool {
302 if self.percentage >= 25 {
303 self.percentage -= 5;
304 return true;
305 }
306 false
307 }
308
309 fn render_chapterlist(&mut self, area: Rect, buf: &mut Buffer) -> Result<()> {
310 let widget = Tree::new(&self.chapter_list.items)
311 .unwrap()
312 .block(Block::bordered().title(utils::convert_str(
313 &self.novel_info.name,
314 &self.config.converts,
315 false,
316 )?))
317 .experimental_scrollbar(Some(
318 Scrollbar::new(ScrollbarOrientation::VerticalRight)
319 .begin_symbol(None)
320 .track_symbol(None)
321 .end_symbol(None),
322 ))
323 .highlight_style(
324 Style::new()
325 .fg(Color::Black)
326 .bg(Color::LightGreen)
327 .add_modifier(Modifier::BOLD),
328 );
329
330 StatefulWidget::render(widget, area, buf, &mut self.chapter_list.state);
331
332 Ok(())
333 }
334
335 fn render_content(&mut self, area: Rect, buf: &mut Buffer) -> Result<()> {
336 if self.chapter_list.state.selected().len() == 2 {
337 let chapter_id = self.chapter_list.state.selected()[1];
338 let chapter_info = self.find_chapter_info(chapter_id).unwrap();
339
340 if chapter_info.payment_required() {
341 let block = Block::bordered().title(utils::convert_str(
342 &chapter_info.title,
343 &self.config.converts,
344 false,
345 )?);
346 Widget::render(block, area, buf);
347
348 self.show_subscription = true;
349 } else {
350 let (content, title) = self.content(chapter_id)?;
351
352 let paragraph = ScrollableParagraph::new(content).title(title);
353 StatefulWidget::render(paragraph, area, buf, &mut self.content_state);
354
355 self.show_subscription = false;
356 }
357 } else {
358 Widget::render(Clear, area, buf);
359 self.show_subscription = false;
360 }
361
362 Ok(())
363 }
364
365 fn render_popup(&mut self, area: Rect, buf: &mut Buffer) -> Result<()> {
366 if self.chapter_list.state.selected().len() == 2 {
367 let chapter_id = self.chapter_list.state.selected()[1];
368 let chapter_info = self.find_chapter_info(chapter_id).unwrap();
369
370 let text = format!(
371 "订阅本章:{},账户余额:{}\n输入 y 订阅",
372 chapter_info.price.unwrap(),
373 self.money
374 );
375 let text = Text::styled(
376 utils::convert_str(text, &self.config.converts, false)?,
377 Style::default().fg(Color::Yellow),
378 );
379 let popup = Popup::new(text).title(utils::convert_str(
380 "订阅章节",
381 &self.config.converts,
382 false,
383 )?);
384 Widget::render(&popup, area, buf);
385 }
386
387 Ok(())
388 }
389
390 fn content(&mut self, chapter_id: u32) -> Result<(String, String)> {
391 let mut result = String::with_capacity(8192);
392 let chapter_info = self.find_chapter_info(chapter_id).unwrap();
393
394 let client = Arc::clone(&self.client);
395 let content_info = task::block_in_place(move || {
396 Handle::current().block_on(async move { client.content_infos(chapter_info).await })
397 })?;
398
399 for info in content_info {
400 if let ContentInfo::Text(text) = info {
401 result.push_str(&utils::convert_str(&text, &self.config.converts, false)?);
402 result.push_str("\n\n");
403 } else if let ContentInfo::Image(url) = info {
404 result.push_str(url.to_string().as_str());
405 result.push_str("\n\n");
406 } else {
407 unreachable!("ContentInfo can only be Text or Image");
408 }
409 }
410
411 while result.ends_with('\n') {
412 result.pop();
413 }
414
415 Ok((
416 result,
417 utils::convert_str(&chapter_info.title, &self.config.converts, false)?,
418 ))
419 }
420
421 fn buy_chapter(&mut self) -> Result<()> {
422 if self.chapter_list.state.selected().len() == 2 {
423 let chapter_id = self.chapter_list.state.selected()[1];
424 let chapter_info = self.find_chapter_info(chapter_id).unwrap();
425
426 let client = Arc::clone(&self.client);
427 task::block_in_place(move || {
428 Handle::current().block_on(async move { client.order_chapter(chapter_info).await })
429 })?;
430
431 let chapter_info = self.find_chapter_info_mut(chapter_id).unwrap();
432 chapter_info.payment_required = Some(false);
433
434 self.money -= chapter_info.price.unwrap() as u32;
435
436 self.chapter_list.items =
437 ChapterList::new(&self.volume_infos, &self.config.converts)?.items;
438 }
439
440 Ok(())
441 }
442
443 fn find_chapter_info(&self, chapter_id: u32) -> Option<&ChapterInfo> {
444 for volume in &self.volume_infos {
445 for chapter in &volume.chapter_infos {
446 if chapter.id == chapter_id {
447 return Some(chapter);
448 }
449 }
450 }
451 None
452 }
453
454 fn find_chapter_info_mut(&mut self, chapter_id: u32) -> Option<&mut ChapterInfo> {
455 for volume in &mut self.volume_infos {
456 for chapter in &mut volume.chapter_infos {
457 if chapter.id == chapter_id {
458 return Some(chapter);
459 }
460 }
461 }
462 None
463 }
464}
465
466impl<T> Widget for &mut App<T>
467where
468 T: Client + Send + Sync + 'static,
469{
470 fn render(self, area: Rect, buf: &mut Buffer) {
471 let layout = Layout::default()
472 .direction(Direction::Horizontal)
473 .constraints(vec![
474 Constraint::Percentage(self.percentage),
475 Constraint::Percentage(100 - self.percentage),
476 ])
477 .split(area);
478
479 self.chapter_list_area = layout[0];
480 self.content_area = layout[1];
481
482 self.render_chapterlist(layout[0], buf).unwrap();
483 self.render_content(layout[1], buf).unwrap();
484
485 if self.show_subscription {
486 self.render_popup(area, buf).unwrap();
487 }
488 }
489}
490
491struct ChapterList {
492 state: TreeState<u32>,
493 items: Vec<TreeItem<'static, u32>>,
494}
495
496impl ChapterList {
497 fn new(volume_infos: &VolumeInfos, converts: &[Convert]) -> Result<Self> {
498 let mut result = Self {
499 state: TreeState::default(),
500 items: Vec::with_capacity(4),
501 };
502
503 for volume_info in volume_infos.iter() {
504 let mut chapters = Vec::with_capacity(32);
505 for chapter in &volume_info.chapter_infos {
506 if chapter.is_valid() {
507 let mut title_prefix = "";
508 if chapter.payment_required() {
509 title_prefix = "【未订阅】";
510 }
511
512 chapters.push(TreeItem::new_leaf(
513 chapter.id,
514 utils::convert_str(
515 format!("{title_prefix}{}", chapter.title),
516 converts,
517 true,
518 )?,
519 ));
520 }
521 }
522
523 if !chapters.is_empty() {
524 result.items.push(
525 TreeItem::new(
526 volume_info.id,
527 utils::convert_str(&volume_info.title, converts, true)?,
528 chapters,
529 )
530 .unwrap(),
531 );
532 }
533 }
534
535 Ok(result)
536 }
537}