novel_cli/cmd/
read.rs

1use std::env;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Duration;
5
6use clap::Args;
7use color_eyre::eyre::{self, Result};
8use fluent_templates::Loader;
9use novel_api::{
10    ChapterInfo, CiweimaoClient, CiyuanjiClient, Client, ContentInfo, NovelInfo, SfacgClient,
11    VolumeInfos,
12};
13use ratatui::Frame;
14use ratatui::buffer::Buffer;
15use ratatui::crossterm::event::{
16    self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
17    MouseEventKind,
18};
19use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
20use ratatui::style::{Color, Modifier, Style};
21use ratatui::text::Text;
22use ratatui::widgets::{Block, Clear, Scrollbar, ScrollbarOrientation, StatefulWidget, Widget};
23use tokio::runtime::Handle;
24use tokio::task;
25use tui_tree_widget::{Tree, TreeItem, TreeState};
26use tui_widgets::popup::Popup;
27use tui_widgets::scrollview::ScrollViewState;
28use url::Url;
29
30use super::{Mode, ScrollableParagraph};
31use crate::cmd::{Convert, Source};
32use crate::{LANG_ID, LOCALES, Tui, utils};
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}