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(¶graph, 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}