pinakes-tui: add book management view and api key authentication
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I20f205d9e06a93a89e8f4433ed6f80576a6a6964
This commit is contained in:
parent
3d9f8933d2
commit
66861b8a20
18 changed files with 917 additions and 251 deletions
|
|
@ -8,13 +8,18 @@ use crossterm::{
|
||||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
client::{ApiClient, AuditEntryResponse},
|
client::{
|
||||||
|
ApiClient,
|
||||||
|
AuditEntryResponse,
|
||||||
|
BookMetadataResponse,
|
||||||
|
ReadingProgressResponse,
|
||||||
|
},
|
||||||
event::{ApiResult, AppEvent, EventHandler},
|
event::{ApiResult, AppEvent, EventHandler},
|
||||||
input::{self, Action},
|
input::{self, Action},
|
||||||
ui,
|
ui,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum View {
|
pub enum View {
|
||||||
Library,
|
Library,
|
||||||
Search,
|
Search,
|
||||||
|
|
@ -30,8 +35,20 @@ pub enum View {
|
||||||
Queue,
|
Queue,
|
||||||
Statistics,
|
Statistics,
|
||||||
Tasks,
|
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 struct AppState {
|
||||||
pub current_view: View,
|
pub current_view: View,
|
||||||
pub media_list: Vec<crate::client::MediaResponse>,
|
pub media_list: Vec<crate::client::MediaResponse>,
|
||||||
|
|
@ -59,6 +76,7 @@ pub struct AppState {
|
||||||
// Multi-select support
|
// Multi-select support
|
||||||
pub selected_items: HashSet<String>,
|
pub selected_items: HashSet<String>,
|
||||||
pub selection_mode: bool,
|
pub selection_mode: bool,
|
||||||
|
pub pending_batch_delete: bool,
|
||||||
// Duplicates view
|
// Duplicates view
|
||||||
pub duplicate_groups: Vec<crate::client::DuplicateGroupResponse>,
|
pub duplicate_groups: Vec<crate::client::DuplicateGroupResponse>,
|
||||||
pub duplicates_selected: Option<usize>,
|
pub duplicates_selected: Option<usize>,
|
||||||
|
|
@ -83,6 +101,18 @@ pub struct AppState {
|
||||||
// Scheduled tasks view
|
// Scheduled tasks view
|
||||||
pub scheduled_tasks: Vec<crate::client::ScheduledTaskResponse>,
|
pub scheduled_tasks: Vec<crate::client::ScheduledTaskResponse>,
|
||||||
pub scheduled_tasks_selected: Option<usize>,
|
pub scheduled_tasks_selected: Option<usize>,
|
||||||
|
// Books view
|
||||||
|
pub books_list: Vec<crate::client::MediaResponse>,
|
||||||
|
pub books_series: Vec<crate::client::SeriesSummary>,
|
||||||
|
pub books_authors: Vec<crate::client::AuthorSummary>,
|
||||||
|
pub books_selected: Option<usize>,
|
||||||
|
pub books_sub_view: BooksSubView,
|
||||||
|
// Book detail metadata
|
||||||
|
pub book_metadata: Option<BookMetadataResponse>,
|
||||||
|
pub reading_progress: Option<ReadingProgressResponse>,
|
||||||
|
// Reading progress input (page number)
|
||||||
|
pub page_input: String,
|
||||||
|
pub entering_page: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -133,6 +163,16 @@ impl AppState {
|
||||||
library_stats: None,
|
library_stats: None,
|
||||||
scheduled_tasks: Vec::new(),
|
scheduled_tasks: Vec::new(),
|
||||||
scheduled_tasks_selected: None,
|
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_offset: 0,
|
||||||
page_size: 50,
|
page_size: 50,
|
||||||
total_media_count: 0,
|
total_media_count: 0,
|
||||||
|
|
@ -140,12 +180,13 @@ impl AppState {
|
||||||
// Multi-select
|
// Multi-select
|
||||||
selected_items: HashSet::new(),
|
selected_items: HashSet::new(),
|
||||||
selection_mode: false,
|
selection_mode: false,
|
||||||
|
pending_batch_delete: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(server_url: &str) -> Result<()> {
|
pub async fn run(server_url: &str, api_key: Option<&str>) -> Result<()> {
|
||||||
let client = ApiClient::new(server_url);
|
let client = ApiClient::new(server_url, api_key);
|
||||||
let mut state = AppState::new(server_url);
|
let mut state = AppState::new(server_url);
|
||||||
|
|
||||||
// Initial data load
|
// Initial data load
|
||||||
|
|
@ -179,9 +220,78 @@ pub async fn run(server_url: &str) -> Result<()> {
|
||||||
if let Some(event) = events.next().await {
|
if let Some(event) = events.next().await {
|
||||||
match event {
|
match event {
|
||||||
AppEvent::Key(key) => {
|
AppEvent::Key(key) => {
|
||||||
let action =
|
// Intercept input when entering reading progress page number
|
||||||
input::handle_key(key, state.input_mode, &state.current_view);
|
if state.entering_page {
|
||||||
handle_action(&client, &mut state, action, &event_sender).await;
|
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::<i32>() {
|
||||||
|
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::Tick => {},
|
||||||
AppEvent::ApiResult(result) => {
|
AppEvent::ApiResult(result) => {
|
||||||
|
|
@ -293,9 +403,27 @@ fn handle_api_result(state: &mut AppState, result: ApiResult) {
|
||||||
state.scheduled_tasks = tasks;
|
state.scheduled_tasks = tasks;
|
||||||
state.status_message = None;
|
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 => {
|
ApiResult::MediaUpdated => {
|
||||||
state.status_message = Some("Media updated".into());
|
state.status_message = Some("Media updated".into());
|
||||||
},
|
},
|
||||||
|
ApiResult::ReadingProgressUpdated => {
|
||||||
|
state.status_message = Some("Reading progress updated".into());
|
||||||
|
},
|
||||||
ApiResult::Error(msg) => {
|
ApiResult::Error(msg) => {
|
||||||
state.status_message = Some(format!("Error: {msg}"));
|
state.status_message = Some(format!("Error: {msg}"));
|
||||||
},
|
},
|
||||||
|
|
@ -316,6 +444,13 @@ async fn handle_action(
|
||||||
View::Tags => state.tags.len(),
|
View::Tags => state.tags.len(),
|
||||||
View::Collections => state.collections.len(),
|
View::Collections => state.collections.len(),
|
||||||
View::Audit => state.audit_log.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(),
|
_ => state.media_list.len(),
|
||||||
};
|
};
|
||||||
if len > 0 {
|
if len > 0 {
|
||||||
|
|
@ -324,9 +459,10 @@ async fn handle_action(
|
||||||
View::Tags => &mut state.tag_selected,
|
View::Tags => &mut state.tag_selected,
|
||||||
View::Collections => &mut state.collection_selected,
|
View::Collections => &mut state.collection_selected,
|
||||||
View::Audit => &mut state.audit_selected,
|
View::Audit => &mut state.audit_selected,
|
||||||
|
View::Books => &mut state.books_selected,
|
||||||
_ => &mut state.selected_index,
|
_ => &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 => {
|
Action::NavigateUp => {
|
||||||
|
|
@ -335,9 +471,10 @@ async fn handle_action(
|
||||||
View::Tags => &mut state.tag_selected,
|
View::Tags => &mut state.tag_selected,
|
||||||
View::Collections => &mut state.collection_selected,
|
View::Collections => &mut state.collection_selected,
|
||||||
View::Audit => &mut state.audit_selected,
|
View::Audit => &mut state.audit_selected,
|
||||||
|
View::Books => &mut state.books_selected,
|
||||||
_ => &mut state.selected_index,
|
_ => &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 => {
|
Action::GoTop => {
|
||||||
let idx = match state.current_view {
|
let idx = match state.current_view {
|
||||||
|
|
@ -345,6 +482,7 @@ async fn handle_action(
|
||||||
View::Tags => &mut state.tag_selected,
|
View::Tags => &mut state.tag_selected,
|
||||||
View::Collections => &mut state.collection_selected,
|
View::Collections => &mut state.collection_selected,
|
||||||
View::Audit => &mut state.audit_selected,
|
View::Audit => &mut state.audit_selected,
|
||||||
|
View::Books => &mut state.books_selected,
|
||||||
_ => &mut state.selected_index,
|
_ => &mut state.selected_index,
|
||||||
};
|
};
|
||||||
*idx = Some(0);
|
*idx = Some(0);
|
||||||
|
|
@ -355,6 +493,13 @@ async fn handle_action(
|
||||||
View::Tags => state.tags.len(),
|
View::Tags => state.tags.len(),
|
||||||
View::Collections => state.collections.len(),
|
View::Collections => state.collections.len(),
|
||||||
View::Audit => state.audit_log.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(),
|
_ => state.media_list.len(),
|
||||||
};
|
};
|
||||||
if len > 0 {
|
if len > 0 {
|
||||||
|
|
@ -363,6 +508,7 @@ async fn handle_action(
|
||||||
View::Tags => &mut state.tag_selected,
|
View::Tags => &mut state.tag_selected,
|
||||||
View::Collections => &mut state.collection_selected,
|
View::Collections => &mut state.collection_selected,
|
||||||
View::Audit => &mut state.audit_selected,
|
View::Audit => &mut state.audit_selected,
|
||||||
|
View::Books => &mut state.books_selected,
|
||||||
_ => &mut state.selected_index,
|
_ => &mut state.selected_index,
|
||||||
};
|
};
|
||||||
*idx = Some(len - 1);
|
*idx = Some(len - 1);
|
||||||
|
|
@ -468,26 +614,29 @@ async fn handle_action(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if let Some(media) = item {
|
if let Some(media) = item {
|
||||||
match client.get_media(&media.id).await {
|
if let Ok(full_media) = client.get_media(&media.id).await {
|
||||||
Ok(full_media) => {
|
// Fetch tags for this media item
|
||||||
// Fetch tags for this media item
|
let media_tags = client.get_media_tags(&full_media.id).await.ok();
|
||||||
let media_tags = client.get_media_tags(&full_media.id).await.ok();
|
// Also fetch all tags for tag/untag operations
|
||||||
// Also fetch all tags for tag/untag operations
|
let all_tags = client.list_tags().await.ok();
|
||||||
let all_tags = client.list_tags().await.ok();
|
// Fetch book metadata for document types
|
||||||
state.selected_media = Some(full_media);
|
state.book_metadata =
|
||||||
if let Some(tags) = media_tags {
|
client.get_book_metadata(&full_media.id).await.ok();
|
||||||
state.tags = tags;
|
state.reading_progress =
|
||||||
}
|
client.get_reading_progress(&full_media.id).await.ok();
|
||||||
if let Some(all) = all_tags {
|
state.selected_media = Some(full_media);
|
||||||
state.all_tags = all;
|
if let Some(tags) = media_tags {
|
||||||
}
|
state.tags = tags;
|
||||||
state.current_view = View::Detail;
|
}
|
||||||
},
|
if let Some(all) = all_tags {
|
||||||
Err(_) => {
|
state.all_tags = all;
|
||||||
state.selected_media = Some(media);
|
}
|
||||||
state.current_view = View::Detail;
|
} 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 => {
|
Action::Open => {
|
||||||
if let Some(ref media) = state.selected_media {
|
if let Some(ref media) = state.selected_media {
|
||||||
match client.open_media(&media.id).await {
|
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}")),
|
Err(e) => state.status_message = Some(format!("Open error: {e}")),
|
||||||
}
|
}
|
||||||
} else if let Some(idx) = state.selected_index
|
} else if let Some(idx) = state.selected_index
|
||||||
&& let Some(media) = state.media_list.get(idx)
|
&& let Some(media) = state.media_list.get(idx)
|
||||||
{
|
{
|
||||||
match client.open_media(&media.id).await {
|
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}")),
|
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()
|
&& let Some(media) = state.media_list.get(idx).cloned()
|
||||||
{
|
{
|
||||||
match client.delete_media(&media.id).await {
|
match client.delete_media(&media.id).await {
|
||||||
Ok(_) => {
|
Ok(()) => {
|
||||||
state.media_list.remove(idx);
|
state.media_list.remove(idx);
|
||||||
if state.media_list.is_empty() {
|
if state.media_list.is_empty() {
|
||||||
state.selected_index = None;
|
state.selected_index = None;
|
||||||
|
|
@ -563,7 +712,7 @@ async fn handle_action(
|
||||||
state.collections = cols;
|
state.collections = cols;
|
||||||
},
|
},
|
||||||
Err(e) => {
|
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 => {
|
Action::ScanTrigger => {
|
||||||
state.status_message = Some("Scanning...".into());
|
state.status_message = Some("Scanning...".into());
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
|
|
@ -803,9 +985,6 @@ async fn handle_action(
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
View::Search => {
|
|
||||||
// Nothing to refresh for search without a query
|
|
||||||
},
|
|
||||||
View::Duplicates => {
|
View::Duplicates => {
|
||||||
match client.find_duplicates().await {
|
match client.find_duplicates().await {
|
||||||
Ok(groups) => {
|
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
|
// No generic refresh for these views
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
Action::NextTab => {
|
Action::NextTab => {
|
||||||
state.current_view = match state.current_view {
|
if state.current_view == View::Books {
|
||||||
View::Library => View::Search,
|
// Cycle through books sub-views
|
||||||
View::Search => View::Tags,
|
state.books_selected = None;
|
||||||
View::Tags => View::Collections,
|
state.books_sub_view = match state.books_sub_view {
|
||||||
View::Collections => View::Audit,
|
BooksSubView::List => BooksSubView::Series,
|
||||||
View::Audit => View::Queue,
|
BooksSubView::Series => BooksSubView::Authors,
|
||||||
View::Queue => View::Statistics,
|
BooksSubView::Authors => BooksSubView::List,
|
||||||
View::Statistics => View::Tasks,
|
};
|
||||||
View::Tasks => View::Library,
|
// Reset selection for the new sub-view
|
||||||
View::Detail
|
let len = match state.books_sub_view {
|
||||||
| View::Import
|
BooksSubView::List => state.books_list.len(),
|
||||||
| View::Settings
|
BooksSubView::Series => state.books_series.len(),
|
||||||
| View::Duplicates
|
BooksSubView::Authors => state.books_authors.len(),
|
||||||
| View::Database
|
};
|
||||||
| View::MetadataEdit => View::Library,
|
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 => {
|
Action::PrevTab => {
|
||||||
state.current_view = match state.current_view {
|
if state.current_view == View::Books {
|
||||||
View::Library => View::Tasks,
|
// Cycle through books sub-views in reverse
|
||||||
View::Search => View::Library,
|
state.books_selected = None;
|
||||||
View::Tags => View::Search,
|
state.books_sub_view = match state.books_sub_view {
|
||||||
View::Collections => View::Tags,
|
BooksSubView::List => BooksSubView::Authors,
|
||||||
View::Audit => View::Collections,
|
BooksSubView::Series => BooksSubView::List,
|
||||||
View::Queue => View::Audit,
|
BooksSubView::Authors => BooksSubView::Series,
|
||||||
View::Statistics => View::Queue,
|
};
|
||||||
View::Tasks => View::Statistics,
|
// Reset selection for the new sub-view
|
||||||
View::Detail
|
let len = match state.books_sub_view {
|
||||||
| View::Import
|
BooksSubView::List => state.books_list.len(),
|
||||||
| View::Settings
|
BooksSubView::Series => state.books_series.len(),
|
||||||
| View::Duplicates
|
BooksSubView::Authors => state.books_authors.len(),
|
||||||
| View::Database
|
};
|
||||||
| View::MetadataEdit => View::Library,
|
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 => {
|
Action::PageDown => {
|
||||||
state.page_offset += state.page_size;
|
state.page_offset += state.page_size;
|
||||||
|
|
@ -963,7 +1203,7 @@ async fn handle_action(
|
||||||
&& let Some(tag) = state.tags.get(idx).cloned()
|
&& let Some(tag) = state.tags.get(idx).cloned()
|
||||||
{
|
{
|
||||||
match client.delete_tag(&tag.id).await {
|
match client.delete_tag(&tag.id).await {
|
||||||
Ok(_) => {
|
Ok(()) => {
|
||||||
state.tags.remove(idx);
|
state.tags.remove(idx);
|
||||||
if state.tags.is_empty() {
|
if state.tags.is_empty() {
|
||||||
state.tag_selected = None;
|
state.tag_selected = None;
|
||||||
|
|
@ -974,7 +1214,7 @@ async fn handle_action(
|
||||||
Some(format!("Deleted tag: {}", tag.name));
|
Some(format!("Deleted tag: {}", tag.name));
|
||||||
},
|
},
|
||||||
Err(e) => {
|
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()
|
&& let Some(col) = state.collections.get(idx).cloned()
|
||||||
{
|
{
|
||||||
match client.delete_collection(&col.id).await {
|
match client.delete_collection(&col.id).await {
|
||||||
Ok(_) => {
|
Ok(()) => {
|
||||||
state.collections.remove(idx);
|
state.collections.remove(idx);
|
||||||
if state.collections.is_empty() {
|
if state.collections.is_empty() {
|
||||||
state.collection_selected = None;
|
state.collection_selected = None;
|
||||||
|
|
@ -995,7 +1235,7 @@ async fn handle_action(
|
||||||
Some(format!("Deleted collection: {}", col.name));
|
Some(format!("Deleted collection: {}", col.name));
|
||||||
},
|
},
|
||||||
Err(e) => {
|
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_id = tag.id.clone();
|
||||||
let tag_name = tag.name.clone();
|
let tag_name = tag.name.clone();
|
||||||
match client.tag_media(&media_id, &tag_id).await {
|
match client.tag_media(&media_id, &tag_id).await {
|
||||||
Ok(_) => {
|
Ok(()) => {
|
||||||
state.status_message = Some(format!("Tagged with: {tag_name}"));
|
state.status_message = Some(format!("Tagged with: {tag_name}"));
|
||||||
// Refresh media tags
|
// Refresh media tags
|
||||||
if let Ok(tags) = client.get_media_tags(&media_id).await {
|
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_id = tag.id.clone();
|
||||||
let tag_name = tag.name.clone();
|
let tag_name = tag.name.clone();
|
||||||
match client.untag_media(&media_id, &tag_id).await {
|
match client.untag_media(&media_id, &tag_id).await {
|
||||||
Ok(_) => {
|
Ok(()) => {
|
||||||
state.status_message = Some(format!("Removed tag: {tag_name}"));
|
state.status_message = Some(format!("Removed tag: {tag_name}"));
|
||||||
// Refresh media tags
|
// Refresh media tags
|
||||||
if let Ok(tags) = client.get_media_tags(&media_id).await {
|
if let Ok(tags) = client.get_media_tags(&media_id).await {
|
||||||
|
|
@ -1084,8 +1324,8 @@ async fn handle_action(
|
||||||
Action::Help => {
|
Action::Help => {
|
||||||
state.status_message = Some(
|
state.status_message = Some(
|
||||||
"?: Help q: Quit /: Search i: Import o: Open t: Tags c: \
|
"?: Help q: Quit /: Search i: Import o: Open t: Tags c: \
|
||||||
Collections a: Audit s: Scan S: Settings r: Refresh Home/End: \
|
Collections a: Audit b: Books s: Scan S: Settings r: Refresh \
|
||||||
Top/Bottom"
|
Home/End: Top/Bottom"
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1105,6 +1345,14 @@ async fn handle_action(
|
||||||
state.current_view = View::MetadataEdit;
|
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 => {
|
Action::Vacuum => {
|
||||||
if state.current_view == View::Database {
|
if state.current_view == View::Database {
|
||||||
state.status_message = Some("Vacuuming database...".to_string());
|
state.status_message = Some("Vacuuming database...".to_string());
|
||||||
|
|
@ -1243,7 +1491,7 @@ async fn handle_action(
|
||||||
state.selected_items.insert(id);
|
state.selected_items.insert(id);
|
||||||
}
|
}
|
||||||
let count = state.selected_items.len();
|
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 => {
|
Action::SelectAll => {
|
||||||
|
|
@ -1261,7 +1509,7 @@ async fn handle_action(
|
||||||
state.selected_items.insert(id);
|
state.selected_items.insert(id);
|
||||||
}
|
}
|
||||||
let count = state.selected_items.len();
|
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 => {
|
Action::ClearSelection => {
|
||||||
state.selected_items.clear();
|
state.selected_items.clear();
|
||||||
|
|
@ -1282,41 +1530,44 @@ async fn handle_action(
|
||||||
state.status_message = Some("No items selected".into());
|
state.status_message = Some("No items selected".into());
|
||||||
} else {
|
} else {
|
||||||
let count = state.selected_items.len();
|
let count = state.selected_items.len();
|
||||||
let ids: Vec<String> = state.selected_items.iter().cloned().collect();
|
state.pending_batch_delete = true;
|
||||||
state.status_message = Some(format!("Deleting {} item(s)...", count));
|
state.status_message = Some(format!("Delete {count} item(s)? (y/n)"));
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Action::ConfirmBatchDelete => {
|
||||||
|
let count = state.selected_items.len();
|
||||||
|
let ids: Vec<String> = 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 => {
|
Action::BatchTag => {
|
||||||
if state.selected_items.is_empty() {
|
if state.selected_items.is_empty() {
|
||||||
state.status_message = Some("No items selected".into());
|
state.status_message = Some("No items selected".into());
|
||||||
|
|
@ -1338,7 +1589,7 @@ async fn handle_action(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(e) => {
|
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
|
} 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_id = tag.id.clone();
|
||||||
let tag_name = tag.name.clone();
|
let tag_name = tag.name.clone();
|
||||||
state.status_message =
|
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 client = client.clone();
|
||||||
let tx = event_sender.clone();
|
let tx = event_sender.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|
@ -1357,20 +1608,18 @@ async fn handle_action(
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
match client.tag_media(id, &tag_id).await {
|
match client.tag_media(id, &tag_id).await {
|
||||||
Ok(_) => tagged += 1,
|
Ok(()) => tagged += 1,
|
||||||
Err(e) => errors.push(format!("{}: {}", id, e)),
|
Err(e) => errors.push(format!("{id}: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if errors.is_empty() {
|
if errors.is_empty() {
|
||||||
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
||||||
"Tagged {} item(s) with '{}'",
|
"Tagged {tagged} item(s) with '{tag_name}'"
|
||||||
tagged, tag_name
|
|
||||||
))));
|
))));
|
||||||
} else {
|
} else {
|
||||||
|
let error_count = errors.len();
|
||||||
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
||||||
"Tagged {} item(s), {} error(s)",
|
"Tagged {tagged} item(s), {error_count} error(s)"
|
||||||
tagged,
|
|
||||||
errors.len()
|
|
||||||
))));
|
))));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -145,10 +145,64 @@ pub struct TypeCount {
|
||||||
pub count: u64,
|
pub count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct BookMetadataResponse {
|
||||||
|
pub media_id: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub subtitle: Option<String>,
|
||||||
|
pub publisher: Option<String>,
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub isbn: Option<String>,
|
||||||
|
pub isbn13: Option<String>,
|
||||||
|
pub page_count: Option<i32>,
|
||||||
|
pub series: Option<String>,
|
||||||
|
pub series_index: Option<f64>,
|
||||||
|
pub authors: Vec<BookAuthorResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct BookAuthorResponse {
|
||||||
|
pub name: String,
|
||||||
|
pub role: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct ReadingProgressResponse {
|
||||||
|
pub media_id: String,
|
||||||
|
pub current_page: i32,
|
||||||
|
pub total_pages: Option<i32>,
|
||||||
|
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 {
|
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 {
|
Self {
|
||||||
client: Client::new(),
|
client,
|
||||||
base_url: base_url.trim_end_matches('/').to_string(),
|
base_url: base_url.trim_end_matches('/').to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -346,10 +400,10 @@ impl ApiClient {
|
||||||
&self,
|
&self,
|
||||||
path: Option<&str>,
|
path: Option<&str>,
|
||||||
) -> Result<Vec<ScanResponse>> {
|
) -> Result<Vec<ScanResponse>> {
|
||||||
let body = match path {
|
let body = path.map_or_else(
|
||||||
Some(p) => serde_json::json!({"path": p}),
|
|| serde_json::json!({"path": null}),
|
||||||
None => serde_json::json!({"path": null}),
|
|p| serde_json::json!({"path": p}),
|
||||||
};
|
);
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
.post(self.url("/scan"))
|
.post(self.url("/scan"))
|
||||||
|
|
@ -488,4 +542,90 @@ impl ApiClient {
|
||||||
.error_for_status()?;
|
.error_for_status()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_book_metadata(
|
||||||
|
&self,
|
||||||
|
media_id: &str,
|
||||||
|
) -> Result<BookMetadataResponse> {
|
||||||
|
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<Vec<MediaResponse>> {
|
||||||
|
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<Vec<SeriesSummary>> {
|
||||||
|
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<Vec<AuthorSummary>> {
|
||||||
|
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<ReadingProgressResponse> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,11 @@ pub enum ApiResult {
|
||||||
DatabaseStats(crate::client::DatabaseStatsResponse),
|
DatabaseStats(crate::client::DatabaseStatsResponse),
|
||||||
Statistics(crate::client::LibraryStatisticsResponse),
|
Statistics(crate::client::LibraryStatisticsResponse),
|
||||||
ScheduledTasks(Vec<crate::client::ScheduledTaskResponse>),
|
ScheduledTasks(Vec<crate::client::ScheduledTaskResponse>),
|
||||||
|
BooksList(Vec<crate::client::MediaResponse>),
|
||||||
|
BookSeries(Vec<crate::client::SeriesSummary>),
|
||||||
|
BookAuthors(Vec<crate::client::AuthorSummary>),
|
||||||
MediaUpdated,
|
MediaUpdated,
|
||||||
|
ReadingProgressUpdated,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ pub enum Action {
|
||||||
QueueView,
|
QueueView,
|
||||||
StatisticsView,
|
StatisticsView,
|
||||||
TasksView,
|
TasksView,
|
||||||
|
BooksView,
|
||||||
ScanTrigger,
|
ScanTrigger,
|
||||||
Refresh,
|
Refresh,
|
||||||
NextTab,
|
NextTab,
|
||||||
|
|
@ -49,14 +50,20 @@ pub enum Action {
|
||||||
ClearSelection,
|
ClearSelection,
|
||||||
ToggleSelectionMode,
|
ToggleSelectionMode,
|
||||||
BatchDelete,
|
BatchDelete,
|
||||||
|
ConfirmBatchDelete,
|
||||||
BatchTag,
|
BatchTag,
|
||||||
|
UpdateReadingProgress,
|
||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::missing_const_for_fn,
|
||||||
|
reason = "match arms return non-trivially constructed enum variants"
|
||||||
|
)]
|
||||||
pub fn handle_key(
|
pub fn handle_key(
|
||||||
key: KeyEvent,
|
key: KeyEvent,
|
||||||
in_input_mode: bool,
|
in_input_mode: bool,
|
||||||
current_view: &View,
|
current_view: View,
|
||||||
) -> Action {
|
) -> Action {
|
||||||
if in_input_mode {
|
if in_input_mode {
|
||||||
match (key.code, key.modifiers) {
|
match (key.code, key.modifiers) {
|
||||||
|
|
@ -101,6 +108,12 @@ pub fn handle_key(
|
||||||
_ => Action::None,
|
_ => Action::None,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
(KeyCode::Char('p'), _) => {
|
||||||
|
match current_view {
|
||||||
|
View::Detail => Action::UpdateReadingProgress,
|
||||||
|
_ => Action::None,
|
||||||
|
}
|
||||||
|
},
|
||||||
(KeyCode::Char('t'), _) => {
|
(KeyCode::Char('t'), _) => {
|
||||||
match current_view {
|
match current_view {
|
||||||
View::Tasks => Action::Toggle,
|
View::Tasks => Action::Toggle,
|
||||||
|
|
@ -116,6 +129,7 @@ pub fn handle_key(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(KeyCode::Char('a'), _) => Action::AuditView,
|
(KeyCode::Char('a'), _) => Action::AuditView,
|
||||||
|
(KeyCode::Char('b'), _) => Action::BooksView,
|
||||||
(KeyCode::Char('S'), _) => Action::SettingsView,
|
(KeyCode::Char('S'), _) => Action::SettingsView,
|
||||||
(KeyCode::Char('B'), _) => Action::DatabaseView,
|
(KeyCode::Char('B'), _) => Action::DatabaseView,
|
||||||
(KeyCode::Char('Q'), _) => Action::QueueView,
|
(KeyCode::Char('Q'), _) => Action::QueueView,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ struct Cli {
|
||||||
)]
|
)]
|
||||||
server: String,
|
server: String,
|
||||||
|
|
||||||
|
/// API key for bearer token authentication
|
||||||
|
#[arg(long, env = "PINAKES_API_KEY")]
|
||||||
|
api_key: Option<String>,
|
||||||
|
|
||||||
/// Set log level (trace, debug, info, warn, error)
|
/// Set log level (trace, debug, info, warn, error)
|
||||||
#[arg(long, default_value = "warn")]
|
#[arg(long, default_value = "warn")]
|
||||||
log_level: String,
|
log_level: String,
|
||||||
|
|
@ -53,5 +57,5 @@ async fn main() -> Result<()> {
|
||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
app::run(&cli.server).await
|
app::run(&cli.server, cli.api_key.as_deref()).await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,17 +47,16 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
));
|
));
|
||||||
|
|
||||||
// Truncate media ID for display
|
// Truncate media ID for display
|
||||||
let media_display = entry
|
let media_display = entry.media_id.as_deref().map_or_else(
|
||||||
.media_id
|
|| "-".into(),
|
||||||
.as_deref()
|
|id| {
|
||||||
.map(|id| {
|
|
||||||
if id.len() > 12 {
|
if id.len() > 12 {
|
||||||
format!("{}...", &id[..12])
|
format!("{}...", &id[..12])
|
||||||
} else {
|
} else {
|
||||||
id.to_string()
|
id.to_string()
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
.unwrap_or_else(|| "-".into());
|
);
|
||||||
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
action_cell,
|
action_cell,
|
||||||
|
|
|
||||||
177
crates/pinakes-tui/src/ui/books.rs
Normal file
177
crates/pinakes-tui/src/ui/books.rs
Normal file
|
|
@ -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<Line> = 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<Row> = 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<Row> = 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<Row> = 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);
|
||||||
|
}
|
||||||
|
|
@ -33,8 +33,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
col
|
col
|
||||||
.filter_query
|
.filter_query
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|q| format!("filter: {q}"))
|
.map_or_else(|| "-".to_string(), |q| format!("filter: {q}"))
|
||||||
.unwrap_or_else(|| "-".to_string())
|
|
||||||
} else {
|
} else {
|
||||||
"-".to_string()
|
"-".to_string()
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
Span::raw(pad),
|
Span::raw(pad),
|
||||||
Span::styled(format!("{key:<20}"), label_style),
|
Span::styled(format!("{key:<20}"), label_style),
|
||||||
Span::styled(value.to_string(), value_style),
|
Span::styled(value.clone(), value_style),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,11 @@ use super::{format_date, format_duration, format_size, media_type_color};
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
|
|
||||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
let item = match &state.selected_media {
|
let Some(item) = &state.selected_media else {
|
||||||
Some(item) => item,
|
let msg = Paragraph::new("No item selected")
|
||||||
None => {
|
.block(Block::default().borders(Borders::ALL).title(" Detail "));
|
||||||
let msg = Paragraph::new("No item selected")
|
f.render_widget(msg, area);
|
||||||
.block(Block::default().borders(Borders::ALL).title(" Detail "));
|
return;
|
||||||
f.render_widget(msg, area);
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
|
|
@ -122,10 +119,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
Span::raw(pad),
|
Span::raw(pad),
|
||||||
Span::styled(make_label("Year"), label_style),
|
Span::styled(make_label("Year"), label_style),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
item
|
item.year.map_or_else(|| "-".to_string(), |y| y.to_string()),
|
||||||
.year
|
|
||||||
.map(|y| y.to_string())
|
|
||||||
.unwrap_or_else(|| "-".to_string()),
|
|
||||||
value_style,
|
value_style,
|
||||||
),
|
),
|
||||||
]));
|
]));
|
||||||
|
|
@ -136,8 +130,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
Span::styled(
|
Span::styled(
|
||||||
item
|
item
|
||||||
.duration_secs
|
.duration_secs
|
||||||
.map(format_duration)
|
.map_or_else(|| "-".to_string(), format_duration),
|
||||||
.unwrap_or_else(|| "-".to_string()),
|
|
||||||
value_style,
|
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());
|
lines.push(Line::default());
|
||||||
|
|
||||||
// Section: Timestamps
|
// 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),
|
Span::styled(format_date(&item.updated_at), dim_style),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
let title = if let Some(ref title_str) = item.title {
|
let title = item.title.as_ref().map_or_else(
|
||||||
format!(" Detail: {} ", title_str)
|
|| format!(" Detail: {} ", item.file_name),
|
||||||
} else {
|
|title_str| format!(" Detail: {title_str} "),
|
||||||
format!(" Detail: {} ", item.file_name)
|
);
|
||||||
};
|
|
||||||
|
|
||||||
let detail = Paragraph::new(lines)
|
let detail = Paragraph::new(lines)
|
||||||
.block(Block::default().borders(Borders::ALL).title(title));
|
.block(Block::default().borders(Borders::ALL).title(title));
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
let line = format!(" {} - {}", item.file_name, item.path);
|
let line = format!(" {} - {}", item.file_name, item.path);
|
||||||
let is_selected = state
|
let is_selected = state
|
||||||
.duplicates_selected
|
.duplicates_selected
|
||||||
.map(|sel| sel == list_items.len())
|
.is_some_and(|sel| sel == list_items.len());
|
||||||
.unwrap_or(false);
|
|
||||||
let style = if is_selected {
|
let style = if is_selected {
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Cyan)
|
.fg(Color::Cyan)
|
||||||
|
|
|
||||||
|
|
@ -55,13 +55,9 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
|
||||||
let duration = item
|
let duration = item
|
||||||
.duration_secs
|
.duration_secs
|
||||||
.map(format_duration)
|
.map_or_else(|| "-".to_string(), format_duration);
|
||||||
.unwrap_or_else(|| "-".to_string());
|
|
||||||
|
|
||||||
let year = item
|
let year = item.year.map_or_else(|| "-".to_string(), |y| y.to_string());
|
||||||
.year
|
|
||||||
.map(|y| y.to_string())
|
|
||||||
.unwrap_or_else(|| "-".to_string());
|
|
||||||
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
Cell::from(Span::styled(marker, marker_style)),
|
Cell::from(Span::styled(marker, marker_style)),
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,10 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
let title = if let Some(ref media) = state.selected_media {
|
let title = state.selected_media.as_ref().map_or_else(
|
||||||
format!(" Edit: {} ", media.file_name)
|
|| " Edit Metadata ".to_string(),
|
||||||
} else {
|
|media| format!(" Edit: {} ", media.file_name),
|
||||||
" Edit Metadata ".to_string()
|
);
|
||||||
};
|
|
||||||
|
|
||||||
let header = Paragraph::new(Line::from(Span::styled(
|
let header = Paragraph::new(Line::from(Span::styled(
|
||||||
&title,
|
&title,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
|
pub mod books;
|
||||||
pub mod collections;
|
pub mod collections;
|
||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod detail;
|
pub mod detail;
|
||||||
|
|
@ -24,6 +25,10 @@ use ratatui::{
|
||||||
use crate::app::{AppState, View};
|
use crate::app::{AppState, View};
|
||||||
|
|
||||||
/// Format a file size in bytes into a human-readable string.
|
/// 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 {
|
pub fn format_size(bytes: u64) -> String {
|
||||||
if bytes < 1024 {
|
if bytes < 1024 {
|
||||||
format!("{bytes} B")
|
format!("{bytes} B")
|
||||||
|
|
@ -37,6 +42,11 @@ pub fn format_size(bytes: u64) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format duration in seconds into hh:mm:ss format.
|
/// 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 {
|
pub fn format_duration(secs: f64) -> String {
|
||||||
let total = secs as u64;
|
let total = secs as u64;
|
||||||
let h = total / 3600;
|
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::Queue => queue::render(f, state, chunks[1]),
|
||||||
View::Statistics => statistics::render(f, state, chunks[1]),
|
View::Statistics => statistics::render(f, state, chunks[1]),
|
||||||
View::Tasks => tasks::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]);
|
render_status_bar(f, state, chunks[2]);
|
||||||
|
|
@ -110,6 +121,7 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
"Tags",
|
"Tags",
|
||||||
"Collections",
|
"Collections",
|
||||||
"Audit",
|
"Audit",
|
||||||
|
"Books",
|
||||||
"Queue",
|
"Queue",
|
||||||
"Stats",
|
"Stats",
|
||||||
"Tasks",
|
"Tasks",
|
||||||
|
|
@ -128,9 +140,10 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
View::Tags => 2,
|
View::Tags => 2,
|
||||||
View::Collections => 3,
|
View::Collections => 3,
|
||||||
View::Audit | View::Duplicates | View::Database => 4,
|
View::Audit | View::Duplicates | View::Database => 4,
|
||||||
View::Queue => 5,
|
View::Books => 5,
|
||||||
View::Statistics => 6,
|
View::Queue => 6,
|
||||||
View::Tasks => 7,
|
View::Statistics => 7,
|
||||||
|
View::Tasks => 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
let tabs = Tabs::new(titles)
|
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) {
|
fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
let status = if let Some(ref msg) = state.status_message {
|
let status = state.status_message.as_ref().map_or_else(
|
||||||
msg.clone()
|
|| {
|
||||||
} else {
|
match state.current_view {
|
||||||
match state.current_view {
|
View::Tags => {
|
||||||
View::Tags => {
|
" q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh \
|
||||||
" q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh \
|
Tab:Switch"
|
||||||
Tab:Switch"
|
.to_string()
|
||||||
.to_string()
|
},
|
||||||
},
|
View::Collections => {
|
||||||
View::Collections => {
|
" q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch"
|
||||||
" q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch"
|
.to_string()
|
||||||
.to_string()
|
},
|
||||||
},
|
View::Audit => {
|
||||||
View::Audit => {
|
" q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch"
|
||||||
" q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch".to_string()
|
.to_string()
|
||||||
},
|
},
|
||||||
View::Detail => {
|
View::Detail => {
|
||||||
" q:Quit Esc:Back o:Open e:Edit +:Tag -:Untag r:Refresh ?:Help"
|
" q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag \
|
||||||
.to_string()
|
r:Refresh ?:Help"
|
||||||
},
|
.to_string()
|
||||||
View::Import => {
|
},
|
||||||
" Enter:Import Esc:Cancel s:Scan libraries ?: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::Settings => " q:Quit Esc:Back ?:Help".to_string(),
|
||||||
View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(),
|
View::Duplicates => " q:Quit j/k:Nav r:Refresh Esc:Back".to_string(),
|
||||||
View::MetadataEdit => {
|
View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(),
|
||||||
" Tab:Next field Enter:Save Esc:Cancel".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 \
|
View::Queue => {
|
||||||
S:Shuffle C:Clear"
|
" q:Quit j/k:Nav Enter:Play d:Remove N:Next P:Prev R:Repeat \
|
||||||
.to_string()
|
S:Shuffle C:Clear"
|
||||||
},
|
.to_string()
|
||||||
View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(),
|
},
|
||||||
View::Tasks => {
|
View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(),
|
||||||
" q:Quit j/k:Nav Enter:Toggle R:Run Now r:Refresh Esc:Back"
|
View::Tasks => {
|
||||||
.to_string()
|
" 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"
|
View::Books => {
|
||||||
.to_string(),
|
" 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(
|
let paragraph = Paragraph::new(Line::from(Span::styled(
|
||||||
status,
|
status,
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,10 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
} else {
|
} else {
|
||||||
&item.media_id
|
&item.media_id
|
||||||
};
|
};
|
||||||
let text = if let Some(ref artist) = item.artist {
|
let text = item.artist.as_ref().map_or_else(
|
||||||
format!("{prefix}{} - {} [{}]", item.title, artist, id_suffix)
|
|| format!("{prefix}{} [{id_suffix}]", item.title),
|
||||||
} else {
|
|artist| format!("{prefix}{} - {artist} [{id_suffix}]", item.title),
|
||||||
format!("{prefix}{} [{}]", item.title, id_suffix)
|
);
|
||||||
};
|
|
||||||
|
|
||||||
let style = if is_selected {
|
let style = if is_selected {
|
||||||
Style::default()
|
Style::default()
|
||||||
|
|
|
||||||
|
|
@ -73,21 +73,13 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(" Newest: ", Style::default().fg(Color::Gray)),
|
Span::styled(" Newest: ", Style::default().fg(Color::Gray)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
stats
|
stats.newest_item.as_deref().map_or("-", super::format_date),
|
||||||
.newest_item
|
|
||||||
.as_deref()
|
|
||||||
.map(super::format_date)
|
|
||||||
.unwrap_or("-"),
|
|
||||||
Style::default().fg(Color::White),
|
Style::default().fg(Color::White),
|
||||||
),
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled("Oldest: ", Style::default().fg(Color::Gray)),
|
Span::styled("Oldest: ", Style::default().fg(Color::Gray)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
stats
|
stats.oldest_item.as_deref().map_or("-", super::format_date),
|
||||||
.oldest_item
|
|
||||||
.as_deref()
|
|
||||||
.map(super::format_date)
|
|
||||||
.unwrap_or("-"),
|
|
||||||
Style::default().fg(Color::White),
|
Style::default().fg(Color::White),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
|
||||||
|
|
@ -27,17 +27,15 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve parent tag name from the tags list itself
|
// Resolve parent tag name from the tags list itself
|
||||||
let parent_display = match &tag.parent_id {
|
let parent_display = tag.parent_id.as_ref().map_or_else(
|
||||||
Some(pid) => {
|
|| "-".to_string(),
|
||||||
state
|
|pid| {
|
||||||
.tags
|
state.tags.iter().find(|t| t.id == *pid).map_or_else(
|
||||||
.iter()
|
|| pid.chars().take(8).collect::<String>() + "...",
|
||||||
.find(|t| t.id == *pid)
|
|t| t.name.clone(),
|
||||||
.map(|t| t.name.clone())
|
)
|
||||||
.unwrap_or_else(|| pid.chars().take(8).collect::<String>() + "...")
|
|
||||||
},
|
},
|
||||||
None => "-".to_string(),
|
);
|
||||||
};
|
|
||||||
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
tag.name.clone(),
|
tag.name.clone(),
|
||||||
|
|
|
||||||
|
|
@ -28,16 +28,8 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
Color::DarkGray
|
Color::DarkGray
|
||||||
};
|
};
|
||||||
|
|
||||||
let last_run = task
|
let last_run = task.last_run.as_deref().map_or("-", super::format_date);
|
||||||
.last_run
|
let next_run = task.next_run.as_deref().map_or("-", super::format_date);
|
||||||
.as_deref()
|
|
||||||
.map(super::format_date)
|
|
||||||
.unwrap_or("-");
|
|
||||||
let next_run = task
|
|
||||||
.next_run
|
|
||||||
.as_deref()
|
|
||||||
.map(super::format_date)
|
|
||||||
.unwrap_or("-");
|
|
||||||
let status = task.last_status.as_deref().unwrap_or("-");
|
let status = task.last_status.as_deref().unwrap_or("-");
|
||||||
// Show abbreviated task ID (first 8 chars)
|
// Show abbreviated task ID (first 8 chars)
|
||||||
let task_id_short = if task.id.len() > 8 {
|
let task_id_short = if task.id.len() > 8 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue