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