From 66861b8a20543b5bf0aaa8f64f774cf81caf7eb7 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 8 Mar 2026 00:42:34 +0300 Subject: [PATCH] pinakes-tui: add book management view and api key authentication Signed-off-by: NotAShelf Change-Id: I20f205d9e06a93a89e8f4433ed6f80576a6a6964 --- crates/pinakes-tui/src/app.rs | 489 ++++++++++++++++----- crates/pinakes-tui/src/client.rs | 152 ++++++- crates/pinakes-tui/src/event.rs | 4 + crates/pinakes-tui/src/input.rs | 16 +- crates/pinakes-tui/src/main.rs | 6 +- crates/pinakes-tui/src/ui/audit.rs | 11 +- crates/pinakes-tui/src/ui/books.rs | 177 ++++++++ crates/pinakes-tui/src/ui/collections.rs | 3 +- crates/pinakes-tui/src/ui/database.rs | 2 +- crates/pinakes-tui/src/ui/detail.rs | 120 ++++- crates/pinakes-tui/src/ui/duplicates.rs | 3 +- crates/pinakes-tui/src/ui/library.rs | 8 +- crates/pinakes-tui/src/ui/metadata_edit.rs | 9 +- crates/pinakes-tui/src/ui/mod.rs | 117 +++-- crates/pinakes-tui/src/ui/queue.rs | 9 +- crates/pinakes-tui/src/ui/statistics.rs | 12 +- crates/pinakes-tui/src/ui/tags.rs | 18 +- crates/pinakes-tui/src/ui/tasks.rs | 12 +- 18 files changed, 917 insertions(+), 251 deletions(-) create mode 100644 crates/pinakes-tui/src/ui/books.rs diff --git a/crates/pinakes-tui/src/app.rs b/crates/pinakes-tui/src/app.rs index c4111e2..1642b6d 100644 --- a/crates/pinakes-tui/src/app.rs +++ b/crates/pinakes-tui/src/app.rs @@ -8,13 +8,18 @@ use crossterm::{ use ratatui::{Terminal, backend::CrosstermBackend}; use crate::{ - client::{ApiClient, AuditEntryResponse}, + client::{ + ApiClient, + AuditEntryResponse, + BookMetadataResponse, + ReadingProgressResponse, + }, event::{ApiResult, AppEvent, EventHandler}, input::{self, Action}, ui, }; -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum View { Library, Search, @@ -30,8 +35,20 @@ pub enum View { Queue, Statistics, Tasks, + Books, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BooksSubView { + List, + Series, + Authors, +} + +#[expect( + clippy::struct_excessive_bools, + reason = "TUI state struct accumulates boolean flags naturally" +)] pub struct AppState { pub current_view: View, pub media_list: Vec, @@ -59,6 +76,7 @@ pub struct AppState { // Multi-select support pub selected_items: HashSet, pub selection_mode: bool, + pub pending_batch_delete: bool, // Duplicates view pub duplicate_groups: Vec, pub duplicates_selected: Option, @@ -83,6 +101,18 @@ pub struct AppState { // Scheduled tasks view pub scheduled_tasks: Vec, pub scheduled_tasks_selected: Option, + // Books view + pub books_list: Vec, + pub books_series: Vec, + pub books_authors: Vec, + pub books_selected: Option, + pub books_sub_view: BooksSubView, + // Book detail metadata + pub book_metadata: Option, + pub reading_progress: Option, + // Reading progress input (page number) + pub page_input: String, + pub entering_page: bool, } #[derive(Clone)] @@ -133,6 +163,16 @@ impl AppState { library_stats: None, scheduled_tasks: Vec::new(), scheduled_tasks_selected: None, + // Books view + books_list: Vec::new(), + books_series: Vec::new(), + books_authors: Vec::new(), + books_selected: None, + books_sub_view: BooksSubView::List, + book_metadata: None, + reading_progress: None, + page_input: String::new(), + entering_page: false, page_offset: 0, page_size: 50, total_media_count: 0, @@ -140,12 +180,13 @@ impl AppState { // Multi-select selected_items: HashSet::new(), selection_mode: false, + pending_batch_delete: false, } } } -pub async fn run(server_url: &str) -> Result<()> { - let client = ApiClient::new(server_url); +pub async fn run(server_url: &str, api_key: Option<&str>) -> Result<()> { + let client = ApiClient::new(server_url, api_key); let mut state = AppState::new(server_url); // Initial data load @@ -179,9 +220,78 @@ pub async fn run(server_url: &str) -> Result<()> { if let Some(event) = events.next().await { match event { AppEvent::Key(key) => { - let action = - input::handle_key(key, state.input_mode, &state.current_view); - handle_action(&client, &mut state, action, &event_sender).await; + // Intercept input when entering reading progress page number + if state.entering_page { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Char(c) if c.is_ascii_digit() => { + state.page_input.push(c); + state.status_message = + Some(format!("Page number: {}", state.page_input)); + }, + KeyCode::Backspace => { + state.page_input.pop(); + state.status_message = if state.page_input.is_empty() { + Some( + "Enter page number (Enter to confirm, Esc to cancel)" + .into(), + ) + } else { + Some(format!("Page number: {}", state.page_input)) + }; + }, + KeyCode::Enter => { + state.entering_page = false; + if let Ok(page) = state.page_input.parse::() { + if let Some(ref media) = state.selected_media { + let media_id = media.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client + .update_reading_progress(&media_id, page) + .await + { + Ok(()) => { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::ReadingProgressUpdated, + )); + }, + Err(e) => { + tracing::error!( + "Failed to update reading progress: {e}" + ); + }, + } + }); + } + } else { + state.status_message = Some("Invalid page number".into()); + } + state.page_input.clear(); + }, + KeyCode::Esc => { + state.entering_page = false; + state.page_input.clear(); + state.status_message = None; + }, + _ => {}, + } + // Intercept y/n when batch delete confirmation is pending + } else if state.pending_batch_delete { + use crossterm::event::KeyCode; + state.pending_batch_delete = false; + if let KeyCode::Char('y' | 'Y') = key.code { + let action = Action::ConfirmBatchDelete; + handle_action(&client, &mut state, action, &event_sender).await; + } else { + state.status_message = Some("Batch delete cancelled".into()); + } + } else { + let action = + input::handle_key(key, state.input_mode, state.current_view); + handle_action(&client, &mut state, action, &event_sender).await; + } }, AppEvent::Tick => {}, AppEvent::ApiResult(result) => { @@ -293,9 +403,27 @@ fn handle_api_result(state: &mut AppState, result: ApiResult) { state.scheduled_tasks = tasks; state.status_message = None; }, + ApiResult::BooksList(items) => { + if !items.is_empty() && state.books_selected.is_none() { + state.books_selected = Some(0); + } + state.books_list = items; + state.status_message = None; + }, + ApiResult::BookSeries(series) => { + state.books_series = series; + state.status_message = None; + }, + ApiResult::BookAuthors(authors) => { + state.books_authors = authors; + state.status_message = None; + }, ApiResult::MediaUpdated => { state.status_message = Some("Media updated".into()); }, + ApiResult::ReadingProgressUpdated => { + state.status_message = Some("Reading progress updated".into()); + }, ApiResult::Error(msg) => { state.status_message = Some(format!("Error: {msg}")); }, @@ -316,6 +444,13 @@ async fn handle_action( View::Tags => state.tags.len(), View::Collections => state.collections.len(), View::Audit => state.audit_log.len(), + View::Books => { + match state.books_sub_view { + BooksSubView::List => state.books_list.len(), + BooksSubView::Series => state.books_series.len(), + BooksSubView::Authors => state.books_authors.len(), + } + }, _ => state.media_list.len(), }; if len > 0 { @@ -324,9 +459,10 @@ async fn handle_action( View::Tags => &mut state.tag_selected, View::Collections => &mut state.collection_selected, View::Audit => &mut state.audit_selected, + View::Books => &mut state.books_selected, _ => &mut state.selected_index, }; - *idx = Some(idx.map(|i| (i + 1).min(len - 1)).unwrap_or(0)); + *idx = Some(idx.map_or(0, |i| (i + 1).min(len - 1))); } }, Action::NavigateUp => { @@ -335,9 +471,10 @@ async fn handle_action( View::Tags => &mut state.tag_selected, View::Collections => &mut state.collection_selected, View::Audit => &mut state.audit_selected, + View::Books => &mut state.books_selected, _ => &mut state.selected_index, }; - *idx = Some(idx.map(|i| i.saturating_sub(1)).unwrap_or(0)); + *idx = Some(idx.map_or(0, |i| i.saturating_sub(1))); }, Action::GoTop => { let idx = match state.current_view { @@ -345,6 +482,7 @@ async fn handle_action( View::Tags => &mut state.tag_selected, View::Collections => &mut state.collection_selected, View::Audit => &mut state.audit_selected, + View::Books => &mut state.books_selected, _ => &mut state.selected_index, }; *idx = Some(0); @@ -355,6 +493,13 @@ async fn handle_action( View::Tags => state.tags.len(), View::Collections => state.collections.len(), View::Audit => state.audit_log.len(), + View::Books => { + match state.books_sub_view { + BooksSubView::List => state.books_list.len(), + BooksSubView::Series => state.books_series.len(), + BooksSubView::Authors => state.books_authors.len(), + } + }, _ => state.media_list.len(), }; if len > 0 { @@ -363,6 +508,7 @@ async fn handle_action( View::Tags => &mut state.tag_selected, View::Collections => &mut state.collection_selected, View::Audit => &mut state.audit_selected, + View::Books => &mut state.books_selected, _ => &mut state.selected_index, }; *idx = Some(len - 1); @@ -468,26 +614,29 @@ async fn handle_action( }, }; if let Some(media) = item { - match client.get_media(&media.id).await { - Ok(full_media) => { - // Fetch tags for this media item - let media_tags = client.get_media_tags(&full_media.id).await.ok(); - // Also fetch all tags for tag/untag operations - let all_tags = client.list_tags().await.ok(); - state.selected_media = Some(full_media); - if let Some(tags) = media_tags { - state.tags = tags; - } - if let Some(all) = all_tags { - state.all_tags = all; - } - state.current_view = View::Detail; - }, - Err(_) => { - state.selected_media = Some(media); - state.current_view = View::Detail; - }, + if let Ok(full_media) = client.get_media(&media.id).await { + // Fetch tags for this media item + let media_tags = client.get_media_tags(&full_media.id).await.ok(); + // Also fetch all tags for tag/untag operations + let all_tags = client.list_tags().await.ok(); + // Fetch book metadata for document types + state.book_metadata = + client.get_book_metadata(&full_media.id).await.ok(); + state.reading_progress = + client.get_reading_progress(&full_media.id).await.ok(); + state.selected_media = Some(full_media); + if let Some(tags) = media_tags { + state.tags = tags; + } + if let Some(all) = all_tags { + state.all_tags = all; + } + } else { + state.book_metadata = None; + state.reading_progress = None; + state.selected_media = Some(media); } + state.current_view = View::Detail; } } }, @@ -511,14 +660,14 @@ async fn handle_action( Action::Open => { if let Some(ref media) = state.selected_media { match client.open_media(&media.id).await { - Ok(_) => state.status_message = Some("Opened file".into()), + Ok(()) => state.status_message = Some("Opened file".into()), Err(e) => state.status_message = Some(format!("Open error: {e}")), } } else if let Some(idx) = state.selected_index && let Some(media) = state.media_list.get(idx) { match client.open_media(&media.id).await { - Ok(_) => state.status_message = Some("Opened file".into()), + Ok(()) => state.status_message = Some("Opened file".into()), Err(e) => state.status_message = Some(format!("Open error: {e}")), } } @@ -528,7 +677,7 @@ async fn handle_action( && let Some(media) = state.media_list.get(idx).cloned() { match client.delete_media(&media.id).await { - Ok(_) => { + Ok(()) => { state.media_list.remove(idx); if state.media_list.is_empty() { state.selected_index = None; @@ -563,7 +712,7 @@ async fn handle_action( state.collections = cols; }, Err(e) => { - state.status_message = Some(format!("Collections error: {e}")) + state.status_message = Some(format!("Collections error: {e}")); }, } }, @@ -698,6 +847,39 @@ async fn handle_action( } }); }, + Action::BooksView => { + state.current_view = View::Books; + state.books_sub_view = BooksSubView::List; + state.status_message = Some("Loading books...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + let page_size = state.page_size; + tokio::spawn(async move { + match client.list_books(0, page_size).await { + Ok(items) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::BooksList(items))) + { + tracing::warn!("failed to send event: {e}"); + } + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Books: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + }, + } + // Also load series and authors + if let Ok(series) = client.list_series().await { + let _ = tx.send(AppEvent::ApiResult(ApiResult::BookSeries(series))); + } + if let Ok(authors) = client.list_book_authors().await { + let _ = tx.send(AppEvent::ApiResult(ApiResult::BookAuthors(authors))); + } + }); + }, Action::ScanTrigger => { state.status_message = Some("Scanning...".into()); let client = client.clone(); @@ -803,9 +985,6 @@ async fn handle_action( }, } }, - View::Search => { - // Nothing to refresh for search without a query - }, View::Duplicates => { match client.find_duplicates().await { Ok(groups) => { @@ -878,47 +1057,108 @@ async fn handle_action( }, } }, - View::MetadataEdit | View::Queue => { + View::Books => { + match client.list_books(0, page_size).await { + Ok(items) => { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::BooksList(items))); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Refresh books: {e}"), + ))); + }, + } + if let Ok(series) = client.list_series().await { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::BookSeries(series))); + } + if let Ok(authors) = client.list_book_authors().await { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::BookAuthors(authors))); + } + }, + View::Search | View::MetadataEdit | View::Queue => { // No generic refresh for these views }, } }); }, Action::NextTab => { - state.current_view = match state.current_view { - View::Library => View::Search, - View::Search => View::Tags, - View::Tags => View::Collections, - View::Collections => View::Audit, - View::Audit => View::Queue, - View::Queue => View::Statistics, - View::Statistics => View::Tasks, - View::Tasks => View::Library, - View::Detail - | View::Import - | View::Settings - | View::Duplicates - | View::Database - | View::MetadataEdit => View::Library, - }; + if state.current_view == View::Books { + // Cycle through books sub-views + state.books_selected = None; + state.books_sub_view = match state.books_sub_view { + BooksSubView::List => BooksSubView::Series, + BooksSubView::Series => BooksSubView::Authors, + BooksSubView::Authors => BooksSubView::List, + }; + // Reset selection for the new sub-view + let len = match state.books_sub_view { + BooksSubView::List => state.books_list.len(), + BooksSubView::Series => state.books_series.len(), + BooksSubView::Authors => state.books_authors.len(), + }; + if len > 0 { + state.books_selected = Some(0); + } + } else { + state.current_view = match state.current_view { + View::Library => View::Search, + View::Search => View::Tags, + View::Tags => View::Collections, + View::Collections => View::Audit, + View::Audit => View::Books, + View::Books => View::Queue, + View::Queue => View::Statistics, + View::Statistics => View::Tasks, + View::Tasks + | View::Detail + | View::Import + | View::Settings + | View::Duplicates + | View::Database + | View::MetadataEdit => View::Library, + }; + } }, Action::PrevTab => { - state.current_view = match state.current_view { - View::Library => View::Tasks, - View::Search => View::Library, - View::Tags => View::Search, - View::Collections => View::Tags, - View::Audit => View::Collections, - View::Queue => View::Audit, - View::Statistics => View::Queue, - View::Tasks => View::Statistics, - View::Detail - | View::Import - | View::Settings - | View::Duplicates - | View::Database - | View::MetadataEdit => View::Library, - }; + if state.current_view == View::Books { + // Cycle through books sub-views in reverse + state.books_selected = None; + state.books_sub_view = match state.books_sub_view { + BooksSubView::List => BooksSubView::Authors, + BooksSubView::Series => BooksSubView::List, + BooksSubView::Authors => BooksSubView::Series, + }; + // Reset selection for the new sub-view + let len = match state.books_sub_view { + BooksSubView::List => state.books_list.len(), + BooksSubView::Series => state.books_series.len(), + BooksSubView::Authors => state.books_authors.len(), + }; + if len > 0 { + state.books_selected = Some(0); + } + } else { + state.current_view = match state.current_view { + View::Library => View::Tasks, + View::Search + | View::Detail + | View::Import + | View::Settings + | View::Duplicates + | View::Database + | View::MetadataEdit => View::Library, + View::Tags => View::Search, + View::Collections => View::Tags, + View::Audit => View::Collections, + View::Books => View::Audit, + View::Queue => View::Books, + View::Statistics => View::Queue, + View::Tasks => View::Statistics, + }; + } }, Action::PageDown => { state.page_offset += state.page_size; @@ -963,7 +1203,7 @@ async fn handle_action( && let Some(tag) = state.tags.get(idx).cloned() { match client.delete_tag(&tag.id).await { - Ok(_) => { + Ok(()) => { state.tags.remove(idx); if state.tags.is_empty() { state.tag_selected = None; @@ -974,7 +1214,7 @@ async fn handle_action( Some(format!("Deleted tag: {}", tag.name)); }, Err(e) => { - state.status_message = Some(format!("Delete error: {e}")) + state.status_message = Some(format!("Delete error: {e}")); }, } } @@ -984,7 +1224,7 @@ async fn handle_action( && let Some(col) = state.collections.get(idx).cloned() { match client.delete_collection(&col.id).await { - Ok(_) => { + Ok(()) => { state.collections.remove(idx); if state.collections.is_empty() { state.collection_selected = None; @@ -995,7 +1235,7 @@ async fn handle_action( Some(format!("Deleted collection: {}", col.name)); }, Err(e) => { - state.status_message = Some(format!("Delete error: {e}")) + state.status_message = Some(format!("Delete error: {e}")); }, } } @@ -1034,7 +1274,7 @@ async fn handle_action( let tag_id = tag.id.clone(); let tag_name = tag.name.clone(); match client.tag_media(&media_id, &tag_id).await { - Ok(_) => { + Ok(()) => { state.status_message = Some(format!("Tagged with: {tag_name}")); // Refresh media tags if let Ok(tags) = client.get_media_tags(&media_id).await { @@ -1063,7 +1303,7 @@ async fn handle_action( let tag_id = tag.id.clone(); let tag_name = tag.name.clone(); match client.untag_media(&media_id, &tag_id).await { - Ok(_) => { + Ok(()) => { state.status_message = Some(format!("Removed tag: {tag_name}")); // Refresh media tags if let Ok(tags) = client.get_media_tags(&media_id).await { @@ -1084,8 +1324,8 @@ async fn handle_action( Action::Help => { state.status_message = Some( "?: Help q: Quit /: Search i: Import o: Open t: Tags c: \ - Collections a: Audit s: Scan S: Settings r: Refresh Home/End: \ - Top/Bottom" + Collections a: Audit b: Books s: Scan S: Settings r: Refresh \ + Home/End: Top/Bottom" .into(), ); }, @@ -1105,6 +1345,14 @@ async fn handle_action( state.current_view = View::MetadataEdit; } }, + Action::UpdateReadingProgress => { + if state.current_view == View::Detail && state.selected_media.is_some() { + state.entering_page = true; + state.page_input.clear(); + state.status_message = + Some("Enter page number (Enter to confirm, Esc to cancel)".into()); + } + }, Action::Vacuum => { if state.current_view == View::Database { state.status_message = Some("Vacuuming database...".to_string()); @@ -1243,7 +1491,7 @@ async fn handle_action( state.selected_items.insert(id); } let count = state.selected_items.len(); - state.status_message = Some(format!("{} item(s) selected", count)); + state.status_message = Some(format!("{count} item(s) selected")); } }, Action::SelectAll => { @@ -1261,7 +1509,7 @@ async fn handle_action( state.selected_items.insert(id); } let count = state.selected_items.len(); - state.status_message = Some(format!("{} item(s) selected", count)); + state.status_message = Some(format!("{count} item(s) selected")); }, Action::ClearSelection => { state.selected_items.clear(); @@ -1282,41 +1530,44 @@ async fn handle_action( state.status_message = Some("No items selected".into()); } else { let count = state.selected_items.len(); - let ids: Vec = state.selected_items.iter().cloned().collect(); - state.status_message = Some(format!("Deleting {} item(s)...", count)); - let client = client.clone(); - let tx = event_sender.clone(); - let page_offset = state.page_offset; - let page_size = state.page_size; - tokio::spawn(async move { - let mut deleted = 0; - let mut errors = Vec::new(); - for id in &ids { - match client.delete_media(id).await { - Ok(_) => deleted += 1, - Err(e) => errors.push(format!("{}: {}", id, e)), - } - } - // Refresh the media list - if let Ok(items) = client.list_media(page_offset, page_size).await { - let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaList(items))); - } - if errors.is_empty() { - let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Deleted {} item(s)", - deleted - )))); - } else { - let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Deleted {} item(s), {} error(s)", - deleted, - errors.len() - )))); - } - }); - state.selected_items.clear(); + state.pending_batch_delete = true; + state.status_message = Some(format!("Delete {count} item(s)? (y/n)")); } }, + Action::ConfirmBatchDelete => { + let count = state.selected_items.len(); + let ids: Vec = state.selected_items.iter().cloned().collect(); + state.status_message = Some(format!("Deleting {count} item(s)...")); + let client = client.clone(); + let tx = event_sender.clone(); + let page_offset = state.page_offset; + let page_size = state.page_size; + tokio::spawn(async move { + let mut deleted = 0; + let mut errors = Vec::new(); + for id in &ids { + match client.delete_media(id).await { + Ok(()) => deleted += 1, + Err(e) => errors.push(format!("{id}: {e}")), + } + } + // Refresh the media list + if let Ok(items) = client.list_media(page_offset, page_size).await { + let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaList(items))); + } + if errors.is_empty() { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Deleted {deleted} item(s)" + )))); + } else { + let error_count = errors.len(); + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Deleted {deleted} item(s), {error_count} error(s)" + )))); + } + }); + state.selected_items.clear(); + }, Action::BatchTag => { if state.selected_items.is_empty() { state.status_message = Some("No items selected".into()); @@ -1338,7 +1589,7 @@ async fn handle_action( } }, Err(e) => { - state.status_message = Some(format!("Failed to load tags: {e}")) + state.status_message = Some(format!("Failed to load tags: {e}")); }, } } else if let Some(tag_idx) = state.tag_selected @@ -1349,7 +1600,7 @@ async fn handle_action( let tag_id = tag.id.clone(); let tag_name = tag.name.clone(); state.status_message = - Some(format!("Tagging {} item(s) with '{}'...", count, tag_name)); + Some(format!("Tagging {count} item(s) with '{tag_name}'...")); let client = client.clone(); let tx = event_sender.clone(); tokio::spawn(async move { @@ -1357,20 +1608,18 @@ async fn handle_action( let mut errors = Vec::new(); for id in &ids { match client.tag_media(id, &tag_id).await { - Ok(_) => tagged += 1, - Err(e) => errors.push(format!("{}: {}", id, e)), + Ok(()) => tagged += 1, + Err(e) => errors.push(format!("{id}: {e}")), } } if errors.is_empty() { let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Tagged {} item(s) with '{}'", - tagged, tag_name + "Tagged {tagged} item(s) with '{tag_name}'" )))); } else { + let error_count = errors.len(); let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Tagged {} item(s), {} error(s)", - tagged, - errors.len() + "Tagged {tagged} item(s), {error_count} error(s)" )))); } }); diff --git a/crates/pinakes-tui/src/client.rs b/crates/pinakes-tui/src/client.rs index 291d4cb..ad13f08 100644 --- a/crates/pinakes-tui/src/client.rs +++ b/crates/pinakes-tui/src/client.rs @@ -145,10 +145,64 @@ pub struct TypeCount { pub count: u64, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BookMetadataResponse { + pub media_id: String, + pub title: Option, + pub subtitle: Option, + pub publisher: Option, + pub language: Option, + pub isbn: Option, + pub isbn13: Option, + pub page_count: Option, + pub series: Option, + pub series_index: Option, + pub authors: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BookAuthorResponse { + pub name: String, + pub role: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ReadingProgressResponse { + pub media_id: String, + pub current_page: i32, + pub total_pages: Option, + pub status: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SeriesSummary { + pub name: String, + pub count: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuthorSummary { + pub name: String, + pub count: u64, +} + impl ApiClient { - pub fn new(base_url: &str) -> Self { + pub fn new(base_url: &str, api_key: Option<&str>) -> Self { + let client = api_key.map_or_else(Client::new, |key| { + let mut headers = reqwest::header::HeaderMap::new(); + if let Ok(val) = + reqwest::header::HeaderValue::from_str(&format!("Bearer {key}")) + { + headers.insert(reqwest::header::AUTHORIZATION, val); + } + Client::builder() + .default_headers(headers) + .build() + .unwrap_or_default() + }); Self { - client: Client::new(), + client, base_url: base_url.trim_end_matches('/').to_string(), } } @@ -346,10 +400,10 @@ impl ApiClient { &self, path: Option<&str>, ) -> Result> { - let body = match path { - Some(p) => serde_json::json!({"path": p}), - None => serde_json::json!({"path": null}), - }; + let body = path.map_or_else( + || serde_json::json!({"path": null}), + |p| serde_json::json!({"path": p}), + ); let resp = self .client .post(self.url("/scan")) @@ -488,4 +542,90 @@ impl ApiClient { .error_for_status()?; Ok(()) } + + pub async fn get_book_metadata( + &self, + media_id: &str, + ) -> Result { + let resp = self + .client + .get(self.url(&format!("/books/{media_id}/metadata"))) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_books( + &self, + offset: u64, + limit: u64, + ) -> Result> { + let resp = self + .client + .get(self.url("/books")) + .query(&[("offset", offset.to_string()), ("limit", limit.to_string())]) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_series(&self) -> Result> { + let resp = self + .client + .get(self.url("/books/series")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_book_authors(&self) -> Result> { + let resp = self + .client + .get(self.url("/books/authors")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn get_reading_progress( + &self, + media_id: &str, + ) -> Result { + let resp = self + .client + .get(self.url(&format!("/books/{media_id}/progress"))) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn update_reading_progress( + &self, + media_id: &str, + current_page: i32, + ) -> Result<()> { + self + .client + .put(self.url(&format!("/books/{media_id}/progress"))) + .json(&serde_json::json!({"current_page": current_page})) + .send() + .await? + .error_for_status()?; + Ok(()) + } } diff --git a/crates/pinakes-tui/src/event.rs b/crates/pinakes-tui/src/event.rs index b37a8a6..394bc51 100644 --- a/crates/pinakes-tui/src/event.rs +++ b/crates/pinakes-tui/src/event.rs @@ -23,7 +23,11 @@ pub enum ApiResult { DatabaseStats(crate::client::DatabaseStatsResponse), Statistics(crate::client::LibraryStatisticsResponse), ScheduledTasks(Vec), + BooksList(Vec), + BookSeries(Vec), + BookAuthors(Vec), MediaUpdated, + ReadingProgressUpdated, Error(String), } diff --git a/crates/pinakes-tui/src/input.rs b/crates/pinakes-tui/src/input.rs index 1a881da..d7b9d7c 100644 --- a/crates/pinakes-tui/src/input.rs +++ b/crates/pinakes-tui/src/input.rs @@ -24,6 +24,7 @@ pub enum Action { QueueView, StatisticsView, TasksView, + BooksView, ScanTrigger, Refresh, NextTab, @@ -49,14 +50,20 @@ pub enum Action { ClearSelection, ToggleSelectionMode, BatchDelete, + ConfirmBatchDelete, BatchTag, + UpdateReadingProgress, None, } +#[expect( + clippy::missing_const_for_fn, + reason = "match arms return non-trivially constructed enum variants" +)] pub fn handle_key( key: KeyEvent, in_input_mode: bool, - current_view: &View, + current_view: View, ) -> Action { if in_input_mode { match (key.code, key.modifiers) { @@ -101,6 +108,12 @@ pub fn handle_key( _ => Action::None, } }, + (KeyCode::Char('p'), _) => { + match current_view { + View::Detail => Action::UpdateReadingProgress, + _ => Action::None, + } + }, (KeyCode::Char('t'), _) => { match current_view { View::Tasks => Action::Toggle, @@ -116,6 +129,7 @@ pub fn handle_key( } }, (KeyCode::Char('a'), _) => Action::AuditView, + (KeyCode::Char('b'), _) => Action::BooksView, (KeyCode::Char('S'), _) => Action::SettingsView, (KeyCode::Char('B'), _) => Action::DatabaseView, (KeyCode::Char('Q'), _) => Action::QueueView, diff --git a/crates/pinakes-tui/src/main.rs b/crates/pinakes-tui/src/main.rs index e6eda62..3188006 100644 --- a/crates/pinakes-tui/src/main.rs +++ b/crates/pinakes-tui/src/main.rs @@ -21,6 +21,10 @@ struct Cli { )] server: String, + /// API key for bearer token authentication + #[arg(long, env = "PINAKES_API_KEY")] + api_key: Option, + /// Set log level (trace, debug, info, warn, error) #[arg(long, default_value = "warn")] log_level: String, @@ -53,5 +57,5 @@ async fn main() -> Result<()> { .init(); } - app::run(&cli.server).await + app::run(&cli.server, cli.api_key.as_deref()).await } diff --git a/crates/pinakes-tui/src/ui/audit.rs b/crates/pinakes-tui/src/ui/audit.rs index e61dc7b..0317ddc 100644 --- a/crates/pinakes-tui/src/ui/audit.rs +++ b/crates/pinakes-tui/src/ui/audit.rs @@ -47,17 +47,16 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { )); // Truncate media ID for display - let media_display = entry - .media_id - .as_deref() - .map(|id| { + let media_display = entry.media_id.as_deref().map_or_else( + || "-".into(), + |id| { if id.len() > 12 { format!("{}...", &id[..12]) } else { id.to_string() } - }) - .unwrap_or_else(|| "-".into()); + }, + ); Row::new(vec![ action_cell, diff --git a/crates/pinakes-tui/src/ui/books.rs b/crates/pinakes-tui/src/ui/books.rs new file mode 100644 index 0000000..907fed0 --- /dev/null +++ b/crates/pinakes-tui/src/ui/books.rs @@ -0,0 +1,177 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Row, Table, Tabs}, +}; + +use crate::app::{AppState, BooksSubView}; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Sub-tab headers + Constraint::Min(0), // Content area + ]) + .split(area); + + render_sub_tabs(f, state, chunks[0]); + + match state.books_sub_view { + BooksSubView::List => render_book_list(f, state, chunks[1]), + BooksSubView::Series => render_series(f, state, chunks[1]), + BooksSubView::Authors => render_authors(f, state, chunks[1]), + } +} + +fn render_sub_tabs(f: &mut Frame, state: &AppState, area: Rect) { + let titles: Vec = vec!["List", "Series", "Authors"] + .into_iter() + .map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White)))) + .collect(); + + let selected = match state.books_sub_view { + BooksSubView::List => 0, + BooksSubView::Series => 1, + BooksSubView::Authors => 2, + }; + + let tabs = Tabs::new(titles) + .block(Block::default().borders(Borders::ALL).title(" Books ")) + .select(selected) + .style(Style::default().fg(Color::Gray)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + + f.render_widget(tabs, area); +} + +fn render_book_list(f: &mut Frame, state: &AppState, area: Rect) { + let header = Row::new(vec!["Title", "Author", "Format", "Pages"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .books_list + .iter() + .enumerate() + .map(|(i, media)| { + let style = if Some(i) == state.books_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + + let title = media + .title + .as_deref() + .unwrap_or(&media.file_name) + .to_string(); + + let author = media.artist.as_deref().unwrap_or("-").to_string(); + + // Extract format from media_type or file extension + let format = media + .file_name + .rsplit('.') + .next() + .map_or_else(|| media.media_type.clone(), str::to_uppercase); + + // Page count from custom fields if available + let pages = media + .custom_fields + .get("page_count") + .map_or_else(|| "-".to_string(), |f| f.value.clone()); + + Row::new(vec![title, author, format, pages]).style(style) + }) + .collect(); + + let title = format!(" Book List ({}) ", state.books_list.len()); + + let table = Table::new(rows, [ + Constraint::Percentage(40), + Constraint::Percentage(30), + Constraint::Percentage(15), + Constraint::Percentage(15), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); +} + +fn render_series(f: &mut Frame, state: &AppState, area: Rect) { + let header = Row::new(vec!["Series Name", "Books"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .books_series + .iter() + .enumerate() + .map(|(i, series)| { + let style = if Some(i) == state.books_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + + Row::new(vec![series.name.clone(), series.count.to_string()]).style(style) + }) + .collect(); + + let title = format!(" Series ({}) ", state.books_series.len()); + + let table = Table::new(rows, [ + Constraint::Percentage(70), + Constraint::Percentage(30), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); +} + +fn render_authors(f: &mut Frame, state: &AppState, area: Rect) { + let header = Row::new(vec!["Author Name", "Books"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .books_authors + .iter() + .enumerate() + .map(|(i, author)| { + let style = if Some(i) == state.books_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + + Row::new(vec![author.name.clone(), author.count.to_string()]).style(style) + }) + .collect(); + + let title = format!(" Authors ({}) ", state.books_authors.len()); + + let table = Table::new(rows, [ + Constraint::Percentage(70), + Constraint::Percentage(30), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); +} diff --git a/crates/pinakes-tui/src/ui/collections.rs b/crates/pinakes-tui/src/ui/collections.rs index cfa6e80..1ac7884 100644 --- a/crates/pinakes-tui/src/ui/collections.rs +++ b/crates/pinakes-tui/src/ui/collections.rs @@ -33,8 +33,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { col .filter_query .as_deref() - .map(|q| format!("filter: {q}")) - .unwrap_or_else(|| "-".to_string()) + .map_or_else(|| "-".to_string(), |q| format!("filter: {q}")) } else { "-".to_string() }; diff --git a/crates/pinakes-tui/src/ui/database.rs b/crates/pinakes-tui/src/ui/database.rs index f10bc43..43dabd1 100644 --- a/crates/pinakes-tui/src/ui/database.rs +++ b/crates/pinakes-tui/src/ui/database.rs @@ -29,7 +29,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { lines.push(Line::from(vec![ Span::raw(pad), Span::styled(format!("{key:<20}"), label_style), - Span::styled(value.to_string(), value_style), + Span::styled(value.clone(), value_style), ])); } } else { diff --git a/crates/pinakes-tui/src/ui/detail.rs b/crates/pinakes-tui/src/ui/detail.rs index d965c88..9788ca8 100644 --- a/crates/pinakes-tui/src/ui/detail.rs +++ b/crates/pinakes-tui/src/ui/detail.rs @@ -10,14 +10,11 @@ use super::{format_date, format_duration, format_size, media_type_color}; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let item = match &state.selected_media { - Some(item) => item, - None => { - let msg = Paragraph::new("No item selected") - .block(Block::default().borders(Borders::ALL).title(" Detail ")); - f.render_widget(msg, area); - return; - }, + let Some(item) = &state.selected_media else { + let msg = Paragraph::new("No item selected") + .block(Block::default().borders(Borders::ALL).title(" Detail ")); + f.render_widget(msg, area); + return; }; let chunks = Layout::default() @@ -122,10 +119,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { Span::raw(pad), Span::styled(make_label("Year"), label_style), Span::styled( - item - .year - .map(|y| y.to_string()) - .unwrap_or_else(|| "-".to_string()), + item.year.map_or_else(|| "-".to_string(), |y| y.to_string()), value_style, ), ])); @@ -136,8 +130,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { Span::styled( item .duration_secs - .map(format_duration) - .unwrap_or_else(|| "-".to_string()), + .map_or_else(|| "-".to_string(), format_duration), value_style, ), ])); @@ -194,6 +187,96 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { ])); } + // Book metadata section + if let Some(ref book) = state.book_metadata { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "--- Book Metadata ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + if let Some(ref subtitle) = book.subtitle { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Subtitle"), label_style), + Span::styled(subtitle.as_str(), value_style), + ])); + } + if !book.authors.is_empty() { + let authors: Vec<&str> = + book.authors.iter().map(|a| a.name.as_str()).collect(); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Authors"), label_style), + Span::styled(authors.join(", "), value_style), + ])); + } + if let Some(ref publisher) = book.publisher { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Publisher"), label_style), + Span::styled(publisher.as_str(), value_style), + ])); + } + if let Some(isbn) = book.isbn13.as_ref().or(book.isbn.as_ref()) { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("ISBN"), label_style), + Span::styled(isbn.as_str(), value_style), + ])); + } + if let Some(ref language) = book.language { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Language"), label_style), + Span::styled(language.as_str(), value_style), + ])); + } + if let Some(pages) = book.page_count { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Pages"), label_style), + Span::styled(pages.to_string(), value_style), + ])); + } + if let Some(ref series) = book.series { + let series_display = book + .series_index + .map_or_else(|| series.clone(), |idx| format!("{series} #{idx}")); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Series"), label_style), + Span::styled(series_display, value_style), + ])); + } + } + + // Reading progress section + if let Some(ref progress) = state.reading_progress { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "--- Reading Progress ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + let page_display = progress.total_pages.map_or_else( + || format!("Page {}", progress.current_page), + |total| format!("Page {} / {total}", progress.current_page), + ); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Progress"), label_style), + Span::styled(page_display, value_style), + ])); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Status"), label_style), + Span::styled(&progress.status, value_style), + ])); + } + lines.push(Line::default()); // Section: Timestamps @@ -216,11 +299,10 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { Span::styled(format_date(&item.updated_at), dim_style), ])); - let title = if let Some(ref title_str) = item.title { - format!(" Detail: {} ", title_str) - } else { - format!(" Detail: {} ", item.file_name) - }; + let title = item.title.as_ref().map_or_else( + || format!(" Detail: {} ", item.file_name), + |title_str| format!(" Detail: {title_str} "), + ); let detail = Paragraph::new(lines) .block(Block::default().borders(Borders::ALL).title(title)); diff --git a/crates/pinakes-tui/src/ui/duplicates.rs b/crates/pinakes-tui/src/ui/duplicates.rs index f1a84f2..be0f94b 100644 --- a/crates/pinakes-tui/src/ui/duplicates.rs +++ b/crates/pinakes-tui/src/ui/duplicates.rs @@ -39,8 +39,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let line = format!(" {} - {}", item.file_name, item.path); let is_selected = state .duplicates_selected - .map(|sel| sel == list_items.len()) - .unwrap_or(false); + .is_some_and(|sel| sel == list_items.len()); let style = if is_selected { Style::default() .fg(Color::Cyan) diff --git a/crates/pinakes-tui/src/ui/library.rs b/crates/pinakes-tui/src/ui/library.rs index 1a2065c..3f3f72f 100644 --- a/crates/pinakes-tui/src/ui/library.rs +++ b/crates/pinakes-tui/src/ui/library.rs @@ -55,13 +55,9 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let duration = item .duration_secs - .map(format_duration) - .unwrap_or_else(|| "-".to_string()); + .map_or_else(|| "-".to_string(), format_duration); - let year = item - .year - .map(|y| y.to_string()) - .unwrap_or_else(|| "-".to_string()); + let year = item.year.map_or_else(|| "-".to_string(), |y| y.to_string()); Row::new(vec![ Cell::from(Span::styled(marker, marker_style)), diff --git a/crates/pinakes-tui/src/ui/metadata_edit.rs b/crates/pinakes-tui/src/ui/metadata_edit.rs index e845c14..26cddd5 100644 --- a/crates/pinakes-tui/src/ui/metadata_edit.rs +++ b/crates/pinakes-tui/src/ui/metadata_edit.rs @@ -15,11 +15,10 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { .split(area); // Header - let title = if let Some(ref media) = state.selected_media { - format!(" Edit: {} ", media.file_name) - } else { - " Edit Metadata ".to_string() - }; + let title = state.selected_media.as_ref().map_or_else( + || " Edit Metadata ".to_string(), + |media| format!(" Edit: {} ", media.file_name), + ); let header = Paragraph::new(Line::from(Span::styled( &title, diff --git a/crates/pinakes-tui/src/ui/mod.rs b/crates/pinakes-tui/src/ui/mod.rs index 726de94..01b3f18 100644 --- a/crates/pinakes-tui/src/ui/mod.rs +++ b/crates/pinakes-tui/src/ui/mod.rs @@ -1,4 +1,5 @@ pub mod audit; +pub mod books; pub mod collections; pub mod database; pub mod detail; @@ -24,6 +25,10 @@ use ratatui::{ use crate::app::{AppState, View}; /// Format a file size in bytes into a human-readable string. +#[expect( + clippy::cast_precision_loss, + reason = "file sizes beyond 2^52 bytes are unlikely in practice" +)] pub fn format_size(bytes: u64) -> String { if bytes < 1024 { format!("{bytes} B") @@ -37,6 +42,11 @@ pub fn format_size(bytes: u64) -> String { } /// Format duration in seconds into hh:mm:ss format. +#[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "duration seconds are always non-negative and within u64 range" +)] pub fn format_duration(secs: f64) -> String { let total = secs as u64; let h = total / 3600; @@ -98,6 +108,7 @@ pub fn render(f: &mut Frame, state: &AppState) { View::Queue => queue::render(f, state, chunks[1]), View::Statistics => statistics::render(f, state, chunks[1]), View::Tasks => tasks::render(f, state, chunks[1]), + View::Books => books::render(f, state, chunks[1]), } render_status_bar(f, state, chunks[2]); @@ -110,6 +121,7 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) { "Tags", "Collections", "Audit", + "Books", "Queue", "Stats", "Tasks", @@ -128,9 +140,10 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) { View::Tags => 2, View::Collections => 3, View::Audit | View::Duplicates | View::Database => 4, - View::Queue => 5, - View::Statistics => 6, - View::Tasks => 7, + View::Books => 5, + View::Queue => 6, + View::Statistics => 7, + View::Tasks => 8, }; let tabs = Tabs::new(titles) @@ -147,50 +160,60 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) { } fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) { - let status = if let Some(ref msg) = state.status_message { - msg.clone() - } else { - match state.current_view { - View::Tags => { - " q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh \ - Tab:Switch" - .to_string() - }, - View::Collections => { - " q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch" - .to_string() - }, - View::Audit => { - " q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch".to_string() - }, - View::Detail => { - " q:Quit Esc:Back o:Open e:Edit +:Tag -:Untag r:Refresh ?:Help" - .to_string() - }, - View::Import => { - " Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string() - }, - View::Settings => " q:Quit Esc:Back ?:Help".to_string(), - View::Duplicates => " q:Quit j/k:Nav r:Refresh Esc:Back".to_string(), - View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(), - View::MetadataEdit => { - " Tab:Next field Enter:Save Esc:Cancel".to_string() - }, - View::Queue => { - " q:Quit j/k:Nav Enter:Play d:Remove N:Next P:Prev R:Repeat \ - S:Shuffle C:Clear" - .to_string() - }, - View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(), - View::Tasks => { - " q:Quit j/k:Nav Enter:Toggle R:Run Now r:Refresh Esc:Back" - .to_string() - }, - _ => " q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit \ - D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help" - .to_string(), - } - }; + let status = state.status_message.as_ref().map_or_else( + || { + match state.current_view { + View::Tags => { + " q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh \ + Tab:Switch" + .to_string() + }, + View::Collections => { + " q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch" + .to_string() + }, + View::Audit => { + " q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch" + .to_string() + }, + View::Detail => { + " q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag \ + r:Refresh ?:Help" + .to_string() + }, + View::Import => { + " Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string() + }, + View::Settings => " q:Quit Esc:Back ?:Help".to_string(), + View::Duplicates => " q:Quit j/k:Nav r:Refresh Esc:Back".to_string(), + View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(), + View::MetadataEdit => { + " Tab:Next field Enter:Save Esc:Cancel".to_string() + }, + View::Queue => { + " q:Quit j/k:Nav Enter:Play d:Remove N:Next P:Prev R:Repeat \ + S:Shuffle C:Clear" + .to_string() + }, + View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(), + View::Tasks => { + " q:Quit j/k:Nav Enter:Toggle R:Run Now r:Refresh Esc:Back" + .to_string() + }, + View::Books => { + " q:Quit j/k:Nav Home/End:Top/Bot Tab:Sub-view r:Refresh \ + Esc:Back" + .to_string() + }, + _ => { + " q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit \ + b:Books D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help" + .to_string() + }, + } + }, + String::clone, + ); let paragraph = Paragraph::new(Line::from(Span::styled( status, diff --git a/crates/pinakes-tui/src/ui/queue.rs b/crates/pinakes-tui/src/ui/queue.rs index d8936d9..af10f4b 100644 --- a/crates/pinakes-tui/src/ui/queue.rs +++ b/crates/pinakes-tui/src/ui/queue.rs @@ -29,11 +29,10 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { } else { &item.media_id }; - let text = if let Some(ref artist) = item.artist { - format!("{prefix}{} - {} [{}]", item.title, artist, id_suffix) - } else { - format!("{prefix}{} [{}]", item.title, id_suffix) - }; + let text = item.artist.as_ref().map_or_else( + || format!("{prefix}{} [{id_suffix}]", item.title), + |artist| format!("{prefix}{} - {artist} [{id_suffix}]", item.title), + ); let style = if is_selected { Style::default() diff --git a/crates/pinakes-tui/src/ui/statistics.rs b/crates/pinakes-tui/src/ui/statistics.rs index 3a1d346..be81bee 100644 --- a/crates/pinakes-tui/src/ui/statistics.rs +++ b/crates/pinakes-tui/src/ui/statistics.rs @@ -73,21 +73,13 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { Line::from(vec![ Span::styled(" Newest: ", Style::default().fg(Color::Gray)), Span::styled( - stats - .newest_item - .as_deref() - .map(super::format_date) - .unwrap_or("-"), + stats.newest_item.as_deref().map_or("-", super::format_date), Style::default().fg(Color::White), ), Span::raw(" "), Span::styled("Oldest: ", Style::default().fg(Color::Gray)), Span::styled( - stats - .oldest_item - .as_deref() - .map(super::format_date) - .unwrap_or("-"), + stats.oldest_item.as_deref().map_or("-", super::format_date), Style::default().fg(Color::White), ), ]), diff --git a/crates/pinakes-tui/src/ui/tags.rs b/crates/pinakes-tui/src/ui/tags.rs index 33347c7..fa91f76 100644 --- a/crates/pinakes-tui/src/ui/tags.rs +++ b/crates/pinakes-tui/src/ui/tags.rs @@ -27,17 +27,15 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { }; // Resolve parent tag name from the tags list itself - let parent_display = match &tag.parent_id { - Some(pid) => { - state - .tags - .iter() - .find(|t| t.id == *pid) - .map(|t| t.name.clone()) - .unwrap_or_else(|| pid.chars().take(8).collect::() + "...") + let parent_display = tag.parent_id.as_ref().map_or_else( + || "-".to_string(), + |pid| { + state.tags.iter().find(|t| t.id == *pid).map_or_else( + || pid.chars().take(8).collect::() + "...", + |t| t.name.clone(), + ) }, - None => "-".to_string(), - }; + ); Row::new(vec![ tag.name.clone(), diff --git a/crates/pinakes-tui/src/ui/tasks.rs b/crates/pinakes-tui/src/ui/tasks.rs index 712c67f..b874c76 100644 --- a/crates/pinakes-tui/src/ui/tasks.rs +++ b/crates/pinakes-tui/src/ui/tasks.rs @@ -28,16 +28,8 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { Color::DarkGray }; - let last_run = task - .last_run - .as_deref() - .map(super::format_date) - .unwrap_or("-"); - let next_run = task - .next_run - .as_deref() - .map(super::format_date) - .unwrap_or("-"); + let last_run = task.last_run.as_deref().map_or("-", super::format_date); + let next_run = task.next_run.as_deref().map_or("-", super::format_date); let status = task.last_status.as_deref().unwrap_or("-"); // Show abbreviated task ID (first 8 chars) let task_id_short = if task.id.len() > 8 {