1use crate::{
2 library::{
3 scan,
4 watch::{self, LibraryEvent},
5 },
6 store::{
7 db::Db,
8 library_view::{list_rows, Filter, Row, Sort},
9 },
10 ui::{
11 library_view::LibraryView,
12 reader_app,
13 terminal::{self, Tui},
14 },
15};
16use anyhow::Result;
17use crossterm::event::{self, Event, KeyCode};
18use ratatui::layout::Rect;
19use std::collections::BTreeMap;
20use std::time::Duration;
21
22pub fn run(
23 db: &Db,
24 library_path: &std::path::Path,
25 keymap_overrides: &BTreeMap<String, Vec<String>>,
26) -> Result<()> {
27 let (watch_rx, _watcher_handle) = watch::spawn_watcher(library_path)?;
28
29 let mut term = terminal::enter()?;
30 let mut selected = 0usize;
31 let mut sort = Sort::LastRead;
32 let mut filter = Filter::All;
33
34 let res = loop_body(
35 &mut term,
36 db,
37 library_path,
38 &mut selected,
39 &mut sort,
40 &mut filter,
41 &watch_rx,
42 keymap_overrides,
43 );
44 terminal::leave(&mut term)?;
45 res
46}
47
48struct Details {
49 path: String,
50 added_at: String,
51 finished_at: Option<String>,
52 parse_error: Option<String>,
53 highlights_count: i64,
54 bookmarks_count: i64,
55}
56
57fn fetch_details(db: &Db, book_id: i64) -> Result<Details> {
58 let conn = db.conn()?;
59 let (path, added_at, finished_at, parse_error): (
60 String,
61 String,
62 Option<String>,
63 Option<String>,
64 ) = conn.query_row(
65 "SELECT path, added_at, finished_at, parse_error FROM books WHERE id = ?",
66 rusqlite::params![book_id],
67 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
68 )?;
69 let (highlights_count, bookmarks_count): (i64, i64) = conn.query_row(
70 "SELECT (SELECT COUNT(*) FROM highlights WHERE book_id = ?),
71 (SELECT COUNT(*) FROM bookmarks WHERE book_id = ?)",
72 rusqlite::params![book_id, book_id],
73 |r| Ok((r.get(0)?, r.get(1)?)),
74 )?;
75 Ok(Details {
76 path,
77 added_at,
78 finished_at,
79 parse_error,
80 highlights_count,
81 bookmarks_count,
82 })
83}
84
85fn build_details_text(row: &Row, d: &Details) -> String {
86 let mut lines = Vec::<String>::new();
87 lines.push(format!("Title: {}", row.title));
88 lines.push(format!(
89 "Author: {}",
90 row.author.clone().unwrap_or_else(|| "—".into())
91 ));
92 lines.push(format!("Path: {}", d.path));
93 lines.push(format!("Added: {}", d.added_at));
94 lines.push(format!(
95 "Finished: {}",
96 d.finished_at.clone().unwrap_or_else(|| "—".into())
97 ));
98 lines.push(format!("Highlights: {}", d.highlights_count));
99 lines.push(format!("Bookmarks: {}", d.bookmarks_count));
100 if let Some(e) = &d.parse_error {
101 lines.push(String::new());
102 lines.push(format!("Parse error: {e}"));
103 }
104 lines.push(String::new());
105 lines.push("[d / Esc to close]".into());
106 lines.join("\n")
107}
108
109#[allow(clippy::too_many_arguments)]
110fn loop_body(
111 term: &mut Tui,
112 db: &Db,
113 library_path: &std::path::Path,
114 selected: &mut usize,
115 sort: &mut Sort,
116 filter: &mut Filter,
117 watch_rx: &crossbeam_channel::Receiver<LibraryEvent>,
118 keymap_overrides: &BTreeMap<String, Vec<String>>,
119) -> Result<()> {
120 let mut details_open = false;
121 loop {
122 let rows: Vec<Row> = list_rows(&db.conn()?, *sort, *filter)?;
123 if !rows.is_empty() {
124 *selected = (*selected).min(rows.len() - 1);
125 }
126
127 let details: Option<Details> = if details_open {
128 rows.get(*selected)
129 .and_then(|r| fetch_details(db, r.book_id).ok())
130 } else {
131 None
132 };
133
134 term.draw(|f| {
135 let area = f.size();
136 LibraryView {
137 rows: &rows,
138 selected: *selected,
139 sort_label: "last-read",
140 filter_label: "all",
141 }
142 .render(f, area);
143
144 if let (true, Some(row), Some(d)) =
145 (details_open, rows.get(*selected), details.as_ref())
146 {
147 let panel = Rect {
148 x: area.x + area.width / 5,
149 y: area.y + area.height / 5,
150 width: (area.width * 3 / 5).max(40),
151 height: (area.height * 3 / 5).max(10),
152 };
153 let details_text = build_details_text(row, d);
154 f.render_widget(ratatui::widgets::Clear, panel);
155 let block = ratatui::widgets::Block::default()
156 .title(" Details ")
157 .borders(ratatui::widgets::Borders::ALL);
158 let para = ratatui::widgets::Paragraph::new(details_text).block(block);
159 f.render_widget(para, panel);
160 }
161 })?;
162
163 let mut needs_rescan = false;
164 while let Ok(_ev) = watch_rx.try_recv() {
165 needs_rescan = true;
166 }
167 if needs_rescan {
168 let _ = scan::scan_folder(library_path, db);
169 }
170
171 if event::poll(Duration::from_millis(200))? {
172 if let Event::Key(k) = event::read()? {
173 if details_open {
174 match k.code {
175 KeyCode::Char('d') | KeyCode::Esc => details_open = false,
176 KeyCode::Char('j') | KeyCode::Down if *selected + 1 < rows.len() => {
177 *selected += 1
178 }
179 KeyCode::Char('k') | KeyCode::Up if *selected > 0 => *selected -= 1,
180 _ => {}
181 }
182 } else {
183 match k.code {
184 KeyCode::Char('q') => return Ok(()),
185 KeyCode::Char('j') | KeyCode::Down if *selected + 1 < rows.len() => {
186 *selected += 1
187 }
188 KeyCode::Char('k') | KeyCode::Up if *selected > 0 => *selected -= 1,
189 KeyCode::Char('s') => *sort = cycle_sort(*sort),
190 KeyCode::Char('f') => *filter = cycle_filter(*filter),
191 KeyCode::Char('d') => details_open = true,
192 KeyCode::Esc => {}
193 KeyCode::Enter => {
194 if let Some(row) = rows.get(*selected) {
195 let path: String = db.conn()?.query_row(
196 "SELECT path FROM books WHERE id = ?",
197 rusqlite::params![row.book_id],
198 |r| r.get(0),
199 )?;
200 terminal::leave(term)?;
201 let reader_db = Db::open(db.location())?;
202 reader_app::run_with_epub_and_db(
203 std::path::Path::new(&path),
204 &row.title,
205 Some(reader_db),
206 Some(row.book_id),
207 Some(keymap_overrides),
208 )?;
209 *term = terminal::enter()?;
210 }
211 }
212 _ => {}
213 }
214 }
215 }
216 }
217 }
218}
219
220fn cycle_sort(s: Sort) -> Sort {
221 use Sort::*;
222 match s {
223 LastRead => Title,
224 Title => Author,
225 Author => Progress,
226 Progress => Added,
227 Added => LastRead,
228 }
229}
230fn cycle_filter(f: Filter) -> Filter {
231 use Filter::*;
232 match f {
233 All => Reading,
234 Reading => Unread,
235 Unread => Finished,
236 Finished => Broken,
237 Broken => All,
238 }
239}