Skip to main content

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) if self.chapter_list_state.click_at(pos) => {
285                self.content_state.scroll_to_top();
286                true
287            }
288            _ => false,
289        }
290    }
291
292    fn exit(&mut self) -> bool {
293        self.mode = Mode::Quit;
294        false
295    }
296
297    fn increase(&mut self) -> bool {
298        if self.percentage <= 45 {
299            self.percentage += 5;
300            return true;
301        }
302        false
303    }
304
305    fn reduce(&mut self) -> bool {
306        if self.percentage >= 25 {
307            self.percentage -= 5;
308            return true;
309        }
310        false
311    }
312
313    fn render_chapterlist(&mut self, area: Rect, buf: &mut Buffer) {
314        StatefulWidgetRef::render_ref(&self.chapter_list, area, buf, &mut self.chapter_list_state);
315    }
316
317    fn render_content(&mut self, area: Rect, buf: &mut Buffer) -> Result<()> {
318        if self.chapter_list_state.selected().len() == 2 {
319            let chapter_id = self.chapter_list_state.selected()[1];
320            let chapter_info = self.find_chapter_info(chapter_id).unwrap();
321
322            if chapter_info.payment_required() {
323                let block = Block::bordered().title(utils::convert_str(
324                    &chapter_info.title,
325                    &self.config.converts,
326                    false,
327                )?);
328                Widget::render(block, area, buf);
329
330                self.show_subscription = true;
331            } else if let Some(paragraph) = self.content_map.get(&chapter_id) {
332                StatefulWidgetRef::render_ref(paragraph, area, buf, &mut self.content_state);
333            } else {
334                let (content, title) = self.content(chapter_id)?;
335
336                let paragraph = ScrollableParagraph::new(content).title(title);
337                StatefulWidgetRef::render_ref(&paragraph, area, buf, &mut self.content_state);
338                self.content_map.insert(chapter_id, paragraph);
339
340                self.show_subscription = false;
341            }
342        } else {
343            Widget::render(Clear, area, buf);
344            self.show_subscription = false;
345        }
346
347        Ok(())
348    }
349
350    fn render_popup(&mut self, area: Rect, buf: &mut Buffer) -> Result<()> {
351        if self.chapter_list_state.selected().len() == 2 {
352            let chapter_id = self.chapter_list_state.selected()[1];
353            let chapter_info = self.find_chapter_info(chapter_id).unwrap();
354
355            let text = format!(
356                "订阅本章:{},账户余额:{}\n输入 y 订阅",
357                chapter_info.price.unwrap(),
358                self.money
359            );
360            let text = Text::styled(
361                utils::convert_str(text, &self.config.converts, false)?,
362                Style::default().fg(Color::Yellow),
363            );
364            let popup = Popup::new(text).title(utils::convert_str(
365                "订阅章节",
366                &self.config.converts,
367                false,
368            )?);
369            Widget::render(&popup, area, buf);
370        }
371
372        Ok(())
373    }
374
375    fn content(&mut self, chapter_id: u32) -> Result<(String, String)> {
376        let mut result = String::with_capacity(8192);
377        let chapter_info = self.find_chapter_info(chapter_id).unwrap();
378
379        let client = Arc::clone(&self.client);
380        let content_info = task::block_in_place(move || {
381            Handle::current().block_on(async move { client.content_infos(chapter_info).await })
382        })?;
383
384        for info in content_info {
385            if let ContentInfo::Text(text) = info {
386                result.push_str(&utils::convert_str(&text, &self.config.converts, false)?);
387                result.push_str("\n\n");
388            } else if let ContentInfo::Image(url) = info {
389                result.push_str(url.to_string().as_str());
390                result.push_str("\n\n");
391            } else {
392                unreachable!("ContentInfo can only be Text or Image");
393            }
394        }
395
396        while result.ends_with('\n') {
397            result.pop();
398        }
399
400        Ok((
401            result,
402            utils::convert_str(&chapter_info.title, &self.config.converts, false)?,
403        ))
404    }
405
406    fn buy_chapter(&mut self) -> Result<()> {
407        if self.chapter_list_state.selected().len() == 2 {
408            let chapter_id = self.chapter_list_state.selected()[1];
409            let chapter_info = self.find_chapter_info(chapter_id).unwrap();
410
411            let client = Arc::clone(&self.client);
412            task::block_in_place(move || {
413                Handle::current().block_on(async move { client.order_chapter(chapter_info).await })
414            })?;
415
416            let chapter_info = self.find_chapter_info_mut(chapter_id).unwrap();
417            chapter_info.payment_required = Some(false);
418
419            self.money -= chapter_info.price.unwrap() as u32;
420
421            self.chapter_list.items = ChapterList::build(
422                &self.novel_info,
423                &self.volume_infos,
424                &self.config.converts,
425                false,
426                false,
427            )?
428            .items;
429        }
430
431        Ok(())
432    }
433
434    fn find_chapter_info(&self, chapter_id: u32) -> Option<&ChapterInfo> {
435        for volume in &self.volume_infos {
436            for chapter in &volume.chapter_infos {
437                if chapter.id == chapter_id {
438                    return Some(chapter);
439                }
440            }
441        }
442        None
443    }
444
445    fn find_chapter_info_mut(&mut self, chapter_id: u32) -> Option<&mut ChapterInfo> {
446        for volume in &mut self.volume_infos {
447            for chapter in &mut volume.chapter_infos {
448                if chapter.id == chapter_id {
449                    return Some(chapter);
450                }
451            }
452        }
453        None
454    }
455}
456
457impl<T> Widget for &mut App<T>
458where
459    T: Client + Send + Sync + 'static,
460{
461    fn render(self, area: Rect, buf: &mut Buffer) {
462        let layout = Layout::default()
463            .direction(Direction::Horizontal)
464            .constraints(vec![
465                Constraint::Percentage(self.percentage),
466                Constraint::Percentage(100 - self.percentage),
467            ])
468            .split(area);
469
470        self.chapter_list_area = layout[0];
471        self.content_area = layout[1];
472
473        self.render_chapterlist(layout[0], buf);
474        self.render_content(layout[1], buf).unwrap();
475
476        if self.show_subscription {
477            self.render_popup(area, buf).unwrap();
478        }
479    }
480}