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 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<crate::client::MediaResponse>,
|
||||
|
|
@ -59,6 +76,7 @@ pub struct AppState {
|
|||
// Multi-select support
|
||||
pub selected_items: HashSet<String>,
|
||||
pub selection_mode: bool,
|
||||
pub pending_batch_delete: bool,
|
||||
// Duplicates view
|
||||
pub duplicate_groups: Vec<crate::client::DuplicateGroupResponse>,
|
||||
pub duplicates_selected: Option<usize>,
|
||||
|
|
@ -83,6 +101,18 @@ pub struct AppState {
|
|||
// Scheduled tasks view
|
||||
pub scheduled_tasks: Vec<crate::client::ScheduledTaskResponse>,
|
||||
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)]
|
||||
|
|
@ -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::<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::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<String> = 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<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 => {
|
||||
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)"
|
||||
))));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue