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