novel_cli/cmd/
read.rs

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}