diff --git a/src/db/app_db.rs b/src/db/app_db.rs index 9441463..e4d3654 100644 --- a/src/db/app_db.rs +++ b/src/db/app_db.rs @@ -364,6 +364,20 @@ impl AppDb { book_id INTEGER NOT NULL PRIMARY KEY, version INTEGER NOT NULL )"#, + // Per-book metadata overrides. A non-NULL column replaces the value on + // the `books` row when read (COALESCE at query time); NULL falls back + // to the source value. Kept in its own table (not on the `books` row) + // so edits survive the startup Calibre re-ingest, which deletes and + // reinserts calibre rows. `authors` is a JSON array (same encoding as + // `books.authors`). + r#"CREATE TABLE IF NOT EXISTS book_meta_overrides ( + book_id INTEGER NOT NULL PRIMARY KEY, + title TEXT, + authors TEXT, + series_name TEXT, + series_index REAL, + description TEXT + )"#, ]; for sql in &statements { @@ -1153,6 +1167,57 @@ impl AppDb { Ok(version) } + /// Store per-book metadata overrides (title, authors, series, description). + /// Each argument replaces the source value on read; passing `None` clears + /// that field's override so it falls back to the Calibre/upload value. The + /// whole override row is rewritten each call. Touches `books.last_modified` + /// so the next device sync re-emits the entitlement with the new metadata. + #[allow(clippy::too_many_arguments)] + pub async fn set_book_meta( + &self, + book_id: i64, + title: Option<&str>, + authors: Option<&str>, + series_name: Option<&str>, + series_index: Option, + description: Option<&str>, + ) -> Result<()> { + sqlx::query( + r#" + INSERT INTO book_meta_overrides + (book_id, title, authors, series_name, series_index, description) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(book_id) DO UPDATE SET + title = excluded.title, + authors = excluded.authors, + series_name = excluded.series_name, + series_index = excluded.series_index, + description = excluded.description + "#, + ) + .bind(book_id) + .bind(title) + .bind(authors) + .bind(series_name) + .bind(series_index) + .bind(description) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to store book metadata override")?; + + sqlx::query( + "UPDATE books SET last_modified = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", + ) + .bind(book_id) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to bump book last_modified after metadata edit")?; + + Ok(()) + } + // ---- Shelf Operations ---- /// Get all shelves for a user. diff --git a/src/db/book_store.rs b/src/db/book_store.rs index 17df852..b74622b 100644 --- a/src/db/book_store.rs +++ b/src/db/book_store.rs @@ -226,16 +226,22 @@ impl BookStore { pub async fn list_catalog(&self, user_id: i64) -> Result> { let rows = sqlx::query( r#" - SELECT b.id, b.uuid, b.title, b.authors, b.publisher, b.language, - b.description, b.series_name, b.series_index, b.file_format, - b.source, b.timestamp, + SELECT b.id, b.uuid, + COALESCE(o.title, b.title) AS title, + COALESCE(o.authors, b.authors) AS authors, + b.publisher, b.language, + COALESCE(o.description, b.description) AS description, + COALESCE(o.series_name, b.series_name) AS series_name, + COALESCE(o.series_index, b.series_index) AS series_index, + b.file_format, b.source, b.timestamp, (b.has_cover != 0 OR c.book_id IS NOT NULL) AS has_cover, COALESCE(c.version, 0) AS cover_version, COALESCE(p.enabled, b.default_sync) AS sync_enabled FROM books b LEFT JOIN book_sync_prefs p ON p.book_id = b.id AND p.user_id = ?1 LEFT JOIN book_covers c ON c.book_id = b.id - ORDER BY b.title COLLATE NOCASE ASC + LEFT JOIN book_meta_overrides o ON o.book_id = b.id + ORDER BY title COLLATE NOCASE ASC "#, ) .bind(user_id) @@ -581,11 +587,22 @@ impl BookStore { } } +// Reads overlay per-book metadata overrides (`book_meta_overrides`): a non-NULL +// override column wins, else the `books` value. The `books`-only columns keep +// their bare names, so an appended `WHERE id = ?` / `WHERE uuid = ?` stays +// unambiguous (the override table has neither column). const SELECT_COLUMNS: &str = r#" - SELECT id, uuid, title, authors, publisher, language, description, - series_name, series_index, pubdate, file_format, file_size, - source, locator, original_filename, timestamp, last_modified - FROM books + SELECT b.id, b.uuid, + COALESCE(o.title, b.title) AS title, + COALESCE(o.authors, b.authors) AS authors, + b.publisher, b.language, + COALESCE(o.description, b.description) AS description, + COALESCE(o.series_name, b.series_name) AS series_name, + COALESCE(o.series_index, b.series_index) AS series_index, + b.pubdate, b.file_format, b.file_size, + b.source, b.locator, b.original_filename, b.timestamp, b.last_modified + FROM books b + LEFT JOIN book_meta_overrides o ON o.book_id = b.id "#; const INSERT_BOOK_SQL: &str = r#" diff --git a/src/web.rs b/src/web.rs index d3879ab..0535305 100644 --- a/src/web.rs +++ b/src/web.rs @@ -92,6 +92,23 @@ struct BookSyncReq { enabled: bool, } +/// Body of `PUT /api/books/{id}/meta` — editable book metadata. Every field is +/// optional; an omitted or blank value clears that override (the book falls back +/// to its Calibre/upload value). +#[derive(Deserialize)] +struct BookMetaReq { + #[serde(default)] + title: Option, + #[serde(default)] + authors: Option>, + #[serde(default)] + series: Option, + #[serde(default)] + series_index: Option, + #[serde(default)] + description: Option, +} + /// Build the web UI router (mounted at server root). /// /// The browser UI is a Leptos client-side SPA (`web-ui/`), served as static @@ -115,6 +132,7 @@ pub fn router(max_upload_size: usize) -> Router { get(api_get_settings).put(api_update_settings), ) .route("/api/books/{book_id}/sync", put(api_set_book_sync)) + .route("/api/books/{book_id}/meta", put(api_set_book_meta)) .route("/api/books/{book_id}/cover/search", get(api_search_cover)) // PUT sets the cover from a chosen search-result URL; POST uploads a // custom image file (multipart, so it needs a body limit). @@ -304,6 +322,57 @@ async fn api_set_book_sync( Ok(StatusCode::NO_CONTENT) } +/// PUT /api/books/{book_id}/meta — Update a book's editable metadata (title, +/// authors, series, description). Stored as a per-book override so Calibre +/// re-ingest does not clobber it; blank fields clear the override. Reflected in +/// the web catalog and on the device at its next sync. +async fn api_set_book_meta( + auth_session: AuthSession, + State(state): State, + Path(book_id): Path, + Json(req): Json, +) -> Result { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + ensure_book_exists(&state, book_id).await?; + + // Treat blank strings as "no override" so clearing a field reverts to source. + let blank = |s: &String| s.trim().is_empty(); + let title = req.title.filter(|s| !blank(s)); + let series = req.series.filter(|s| !blank(s)); + let description = req.description.filter(|s| !blank(s)); + + // Drop blank author entries; an empty list clears the override. + let authors = req.authors.and_then(|list| { + let cleaned: Vec = list + .into_iter() + .map(|a| a.trim().to_string()) + .filter(|a| !a.is_empty()) + .collect(); + (!cleaned.is_empty()) + .then(|| serde_json::to_string(&cleaned).unwrap_or_else(|_| "[]".to_string())) + }); + + state + .app_db + .set_book_meta( + book_id, + title.as_deref(), + authors.as_deref(), + series.as_deref(), + req.series_index, + description.as_deref(), + ) + .await + .map_err(|e| { + error!("Failed to set book metadata: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + })?; + + Ok(StatusCode::NO_CONTENT) +} + /// GET /api/me — Return the currently logged-in user. async fn api_me(auth_session: AuthSession) -> Result, StatusCode> { let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?; diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index 5c2d8eb..f63a381 100644 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -16,6 +16,15 @@ pub struct Book { pub uuid: String, pub title: String, pub authors: Vec, + /// Series name, if any (editable). + #[serde(default)] + pub series: Option, + /// Position within the series (editable). + #[serde(default)] + pub series_index: Option, + /// Book description / blurb (editable). + #[serde(default)] + pub description: Option, pub has_cover: bool, /// `"calibre"` or `"upload"` — only uploads get a delete control. pub source: String, @@ -179,6 +188,26 @@ pub async fn set_book_sync(id: i64, enabled: bool) -> ApiResult<()> { check(resp).await.map(|_| ()) } +/// Editable book metadata sent to `PUT /api/books/{id}/meta`. Blank fields +/// clear the corresponding override on the server. +#[derive(Debug, Clone, Serialize)] +pub struct BookMeta { + pub title: Option, + pub authors: Option>, + pub series: Option, + pub series_index: Option, + pub description: Option, +} + +/// `PUT /api/books/{id}/meta` — update a book's title/authors/series/description. +pub async fn set_book_meta(id: i64, meta: &BookMeta) -> ApiResult<()> { + let resp = gloo_net::http::Request::put(&format!("/api/books/{id}/meta")) + .json(meta)? + .send() + .await?; + check(resp).await.map(|_| ()) +} + /// `GET /api/books/{id}/cover/search?q=…` — candidate covers for a book. pub async fn search_covers(id: i64, query: &str) -> ApiResult> { let encoded = String::from(js_sys::encode_uri_component(query)); diff --git a/web-ui/src/components/book_editor.rs b/web-ui/src/components/book_editor.rs new file mode 100644 index 0000000..fbb0f0c --- /dev/null +++ b/web-ui/src/components/book_editor.rs @@ -0,0 +1,315 @@ +//! Modal to edit a book: its metadata (title, authors, series, description) and +//! its cover. Driven by an `edit` signal (open when `Some(book)`), mirroring the +//! confirm-modal overlay pattern in `library.rs`. +//! +//! Metadata is saved on "Save" (`PUT /api/books/{id}/meta`). A cover change +//! (pick a search result, or upload a file) applies immediately via +//! `PUT`/`POST /api/books/{id}/cover` and refreshes the in-modal preview. Both +//! paths call `reload` so the parent grid re-fetches. + +use leptos::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::spawn_local; +use web_sys::{FormData, HtmlInputElement}; + +use crate::api::{self, ApiResult, Book, BookMeta, CoverCandidate}; +use crate::style::{ + ALERT_ERROR, ALERT_INFO, ALERT_SUCCESS, BTN, BTN_PRIMARY, BTN_SM, INPUT, LABEL, SPINNER, +}; + +#[component] +pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl IntoView { + // ---- metadata fields ---- + let title = RwSignal::new(String::new()); + let authors = RwSignal::new(String::new()); + let series = RwSignal::new(String::new()); + let series_index = RwSignal::new(String::new()); + let description = RwSignal::new(String::new()); + + // ---- cover state ---- + let query = RwSignal::new(String::new()); + let results = RwSignal::new(None::>>); + // Preview cache-buster: `?v=` value for the cover img, bumped on each change. + let cover_bust = RwSignal::new(0i64); + let has_cover = RwSignal::new(false); + + let status = RwSignal::new(None::<(String, &'static str)>); + let busy = RwSignal::new(false); + let file_input: NodeRef = NodeRef::new(); + + // Opening a book fills every field from its current values and resets state. + Effect::new(move |_| { + if let Some(b) = edit.get() { + title.set(b.title.clone()); + authors.set(b.authors.join(", ")); + series.set(b.series.clone().unwrap_or_default()); + series_index.set(b.series_index.map(format_index).unwrap_or_default()); + description.set(b.description.clone().unwrap_or_default()); + query.set(b.title.clone()); + results.set(None); + cover_bust.set(b.cover_version); + has_cover.set(b.has_cover); + status.set(None); + busy.set(false); + } + }); + + // Save the metadata fields, then reload the grid and close. + let save = move || { + let Some(b) = edit.get_untracked() else { + return; + }; + let authors_vec: Vec = authors + .get_untracked() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + let meta = BookMeta { + title: non_blank(title.get_untracked()), + authors: Some(authors_vec), + series: non_blank(series.get_untracked()), + series_index: series_index.get_untracked().trim().parse::().ok(), + description: non_blank(description.get_untracked()), + }; + busy.set(true); + status.set(Some(("Saving\u{2026}".to_string(), ALERT_INFO))); + spawn_local(async move { + match api::set_book_meta(b.id, &meta).await { + Ok(()) => { + reload.run(()); + edit.set(None); + } + Err(err) => { + status.set(Some((format!("Failed to save: {err}"), ALERT_ERROR))); + busy.set(false); + } + } + }); + }; + + let do_search = move || { + let Some(b) = edit.get_untracked() else { + return; + }; + let q = query.get_untracked(); + results.set(None); + busy.set(true); + spawn_local(async move { + let r = api::search_covers(b.id, &q).await; + results.set(Some(r)); + busy.set(false); + }); + }; + + // Apply a chosen candidate URL as the new cover; keep the modal open and + // refresh the preview. + let choose = move |full_url: String| { + let Some(b) = edit.get_untracked() else { + return; + }; + busy.set(true); + status.set(Some(("Updating cover\u{2026}".to_string(), ALERT_INFO))); + spawn_local(async move { + match api::set_cover_url(b.id, &full_url).await { + Ok(()) => { + has_cover.set(true); + cover_bust.update(|v| *v += 1); + results.set(None); + status.set(Some(("Cover updated.".to_string(), ALERT_SUCCESS))); + reload.run(()); + } + Err(err) => status.set(Some(( + format!("Failed to update cover: {err}"), + ALERT_ERROR, + ))), + } + busy.set(false); + }); + }; + + let on_file = move |ev: leptos::ev::Event| { + let Some(b) = edit.get_untracked() else { + return; + }; + let Some(target) = ev.target() else { return }; + let Ok(input) = target.dyn_into::() else { + return; + }; + let Some(file) = input.files().and_then(|f| f.get(0)) else { + return; + }; + let name = file.name(); + let Ok(form) = FormData::new() else { return }; + if form + .append_with_blob_and_filename("file", file.as_ref(), &name) + .is_err() + { + return; + } + busy.set(true); + status.set(Some(("Uploading cover\u{2026}".to_string(), ALERT_INFO))); + spawn_local(async move { + match api::upload_cover(b.id, form).await { + Ok(()) => { + has_cover.set(true); + cover_bust.update(|v| *v += 1); + status.set(Some(("Cover updated.".to_string(), ALERT_SUCCESS))); + reload.run(()); + } + Err(err) => status.set(Some((format!("Cover upload failed: {err}"), ALERT_ERROR))), + } + input.set_value(""); + busy.set(false); + }); + }; + + view! { + {move || edit.get().map(|b| { let book_id = b.id; view! { +
+
+

"Edit book"

+ +
+ // ---- cover column ---- +
+
+ {move || if has_cover.get() { + view! { }.into_any() + } else { + view! { "No cover" }.into_any() + }} +
+ + +
+ + // ---- metadata column ---- +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + // ---- cover search ---- +
+ +
+ + +
+
+ {move || match results.get() { + None => if busy.get() { + view! {
}.into_any() + } else { + view! {

+ "Search to pick a cover, or upload your own image above." +

}.into_any() + }, + Some(Err(err)) => view! { +

{format!("Search failed: {err}")}

+ }.into_any(), + Some(Ok(list)) if list.is_empty() => view! { +

"No covers found."

+ }.into_any(), + Some(Ok(list)) => { + let tiles = list.into_iter().map(|c| { + let full = c.full_url.clone(); + view! { + + } + }).collect_view(); + view! { +
+ {tiles} +
+ }.into_any() + } + }} +
+
+ + {move || status.get().map(|(m, c)| view! {

{m}

})} + +
+ + +
+
+
+ }})} + } +} + +/// `None` for a blank/whitespace string, else the trimmed value. +fn non_blank(s: String) -> Option { + let t = s.trim(); + (!t.is_empty()).then(|| t.to_string()) +} + +/// Render a series index without a trailing `.0` (e.g. `3` not `3.0`, but `3.5`). +fn format_index(i: f64) -> String { + if i.fract() == 0.0 { + format!("{}", i as i64) + } else { + format!("{i}") + } +} diff --git a/web-ui/src/components/cover_picker.rs b/web-ui/src/components/cover_picker.rs deleted file mode 100644 index cfd6305..0000000 --- a/web-ui/src/components/cover_picker.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Modal to change a book's cover: search OpenLibrary/Google for candidates and -//! pick one, or upload a custom image file. Driven by an `edit` signal (open -//! when `Some`), mirroring the confirm-modal overlay pattern in `library.rs`. -//! On a successful change it calls `reload` so the parent grid re-fetches (which -//! also bumps the `/covers/{id}?v=` cache-buster). - -use leptos::prelude::*; -use wasm_bindgen::JsCast; -use wasm_bindgen_futures::spawn_local; -use web_sys::{FormData, HtmlInputElement}; - -use crate::api::{self, ApiResult, CoverCandidate}; -use crate::style::{ALERT_ERROR, ALERT_INFO, BTN, BTN_SM, INPUT, SPINNER}; - -/// The book whose cover is being edited (open state for the picker). -#[derive(Clone)] -pub struct CoverEdit { - pub id: i64, - pub title: String, -} - -#[component] -pub fn CoverPicker(edit: RwSignal>, reload: Callback<()>) -> impl IntoView { - let query = RwSignal::new(String::new()); - let results = RwSignal::new(None::>>); - let status = RwSignal::new(None::<(String, &'static str)>); - let busy = RwSignal::new(false); - let file_input: NodeRef = NodeRef::new(); - - // Opening a book prefills the search box with its title and resets state. - Effect::new(move |_| { - if let Some(e) = edit.get() { - query.set(e.title.clone()); - results.set(None); - status.set(None); - busy.set(false); - } - }); - - let do_search = move || { - let Some(e) = edit.get_untracked() else { - return; - }; - let q = query.get_untracked(); - results.set(None); - busy.set(true); - spawn_local(async move { - let r = api::search_covers(e.id, &q).await; - results.set(Some(r)); - busy.set(false); - }); - }; - - // Save a chosen candidate URL as the new cover, then close + reload. - let choose = move |full_url: String| { - let Some(e) = edit.get_untracked() else { - return; - }; - busy.set(true); - status.set(Some(("Saving\u{2026}".to_string(), ALERT_INFO))); - spawn_local(async move { - match api::set_cover_url(e.id, &full_url).await { - Ok(()) => { - reload.run(()); - edit.set(None); - } - Err(err) => { - status.set(Some((format!("Failed to save cover: {err}"), ALERT_ERROR))); - busy.set(false); - } - } - }); - }; - - let on_file = move |ev: leptos::ev::Event| { - let Some(e) = edit.get_untracked() else { - return; - }; - let Some(target) = ev.target() else { return }; - let Ok(input) = target.dyn_into::() else { - return; - }; - let Some(file) = input.files().and_then(|f| f.get(0)) else { - return; - }; - let name = file.name(); - let Ok(form) = FormData::new() else { return }; - if form - .append_with_blob_and_filename("file", file.as_ref(), &name) - .is_err() - { - return; - } - busy.set(true); - status.set(Some(("Uploading\u{2026}".to_string(), ALERT_INFO))); - spawn_local(async move { - match api::upload_cover(e.id, form).await { - Ok(()) => { - reload.run(()); - edit.set(None); - } - Err(err) => { - status.set(Some((format!("Upload failed: {err}"), ALERT_ERROR))); - busy.set(false); - } - } - input.set_value(""); - }); - }; - - view! { - {move || edit.get().map(|e| view! { -
-
-
-

"Change cover"

-

{e.title.clone()}

-
- -
- - - - -
- - {move || status.get().map(|(m, c)| view! {

{m}

})} - -
- {move || match results.get() { - None => if busy.get() { - view! {
}.into_any() - } else { - view! {

- "Search for a cover, or upload your own image." -

}.into_any() - }, - Some(Err(err)) => view! { -

{format!("Search failed: {err}")}

- }.into_any(), - Some(Ok(list)) if list.is_empty() => view! { -

"No covers found."

- }.into_any(), - Some(Ok(list)) => { - let tiles = list.into_iter().map(|c| { - let full = c.full_url.clone(); - view! { - - } - }).collect_view(); - view! { -
- {tiles} -
- }.into_any() - } - }} -
- -
- -
-
-
- })} - } -} diff --git a/web-ui/src/components/library.rs b/web-ui/src/components/library.rs index 9f5512e..e1a467f 100644 --- a/web-ui/src/components/library.rs +++ b/web-ui/src/components/library.rs @@ -9,7 +9,7 @@ use wasm_bindgen_futures::spawn_local; use web_sys::{FormData, HtmlInputElement}; use crate::api::{self, ApiError, ApiResult, Book, Token, User}; -use crate::components::cover_picker::{CoverEdit, CoverPicker}; +use crate::components::book_editor::BookEditor; use crate::style::{ ALERT_ERROR, ALERT_INFO, ALERT_SUCCESS, BRAND, BTN, BTN_ACCENT, BTN_PRIMARY, BTN_SM, HEADER, LINK, SWITCH, SWITCH_INPUT, SWITCH_SLIDER, @@ -88,7 +88,7 @@ pub fn Library() -> impl IntoView { // ---- ui state ---- let sort = RwSignal::new(Sort::Title); let filter = RwSignal::new(Filter::All); - let cover_edit = RwSignal::new(None::); + let book_edit = RwSignal::new(None::); let generating = RwSignal::new(false); let upload_status = RwSignal::new(None::<(String, &'static str)>); let copied = RwSignal::new(None::); @@ -337,7 +337,7 @@ pub fn Library() -> impl IntoView { let is_upload = b.source == "upload"; let sync_enabled = b.sync_enabled; let title = b.title.clone(); - let edit_title = title.clone(); + let edit_book = b.clone(); let author = if b.authors.is_empty() { "Unknown".to_string() } else { @@ -371,11 +371,9 @@ pub fn Library() -> impl IntoView { rounded-[7px] border-0 bg-black/70 text-white text-[11px] font-[550] \ px-2 py-1 cursor-pointer opacity-0 transition-opacity duration-150 \ group-hover:opacity-100 hover:bg-black/85" - title="Change cover" - on:click=move |_| cover_edit.set(Some(CoverEdit { - id, title: edit_title.clone(), - }))> - "Change cover" + title="Edit book" + on:click=move |_| book_edit.set(Some(edit_book.clone()))> + "Edit"

{title.clone()}

@@ -459,7 +457,7 @@ pub fn Library() -> impl IntoView { } })} - // ---- Change-cover modal ---- - + // ---- Edit-book modal ---- + } } diff --git a/web-ui/src/components/mod.rs b/web-ui/src/components/mod.rs index 7f8c410..01fe720 100644 --- a/web-ui/src/components/mod.rs +++ b/web-ui/src/components/mod.rs @@ -1,7 +1,7 @@ //! UI components, one module per page plus shared pieces. pub mod app; -pub mod cover_picker; +pub mod book_editor; pub mod library; pub mod login; pub mod register; -- 2.51.2 From 2a09c0ee0e58c99e68e2a050e40509abb686b2dc Mon Sep 17 00:00:00 2001 From: servius Date: Mon, 27 Jul 2026 22:01:53 +0530 Subject: [PATCH 2/6] feat: search online metadata to fill the book editor Add a "Find book details online" section to the Edit modal: search Google Books + OpenLibrary and pick a record to fill title, authors, and description (and apply its cover). Companion to the existing cover search, independent of the enrichment config. New `upload/metadata_search.rs` returns multiple candidate records; served via GET /api/books/{id}/meta/search. Google Books descriptions are HTML- stripped to plain text. Picking a record only fills the form fields (cover applies immediately); the user reviews and clicks Save. --- src/upload/metadata_search.rs | 228 +++++++++++++++++++++++++++ src/upload/mod.rs | 1 + src/web.rs | 63 ++++++++ web-ui/src/api.rs | 27 ++++ web-ui/src/components/book_editor.rs | 146 ++++++++++++++++- 5 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 src/upload/metadata_search.rs diff --git a/src/upload/metadata_search.rs b/src/upload/metadata_search.rs new file mode 100644 index 0000000..b450962 --- /dev/null +++ b/src/upload/metadata_search.rs @@ -0,0 +1,228 @@ +//! Online metadata search for the "Edit book" UI. Companion to +//! [`crate::upload::cover_search`]: where that returns candidate cover *images*, +//! this returns candidate *records* (title, authors, description, year, and a +//! cover) a user can pick to fill the edit form. Always available (an explicit +//! user action, independent of the `enrichment` config). + +use std::time::Duration; + +use reqwest::Client; +use serde::Deserialize; + +/// A candidate book record a user can apply to the edit form. Every field is +/// best-effort; providers rarely supply all of them. +#[derive(Debug, Clone)] +pub struct MetadataCandidate { + pub title: Option, + pub authors: Vec, + pub description: Option, + pub year: Option, + /// Small preview image for the results list. + pub cover_thumb_url: Option, + /// Full-resolution cover to store if the user applies this record. Always an + /// allowlisted host (see [`crate::upload::cover_search::is_allowed_cover_url`]). + pub cover_full_url: Option, + pub source: &'static str, +} + +/// Build the shared HTTP client (same shape as `enrich.rs`). +fn client() -> reqwest::Result { + Client::builder() + .user_agent("kobors/0.1 (self-hosted Kobo sync)") + .timeout(Duration::from_secs(8)) + .build() +} + +/// Search Google Books and OpenLibrary for records matching `query` (typically +/// "title author"). Best-effort: provider failures yield no candidates rather +/// than an error. Google Books results come first (they carry descriptions). +pub async fn search_metadata(query: &str) -> Vec { + let query = query.trim(); + if query.is_empty() { + return Vec::new(); + } + let Ok(client) = client() else { + return Vec::new(); + }; + + let mut candidates = Vec::new(); + candidates.extend(google_books(&client, query).await.unwrap_or_default()); + candidates.extend(open_library(&client, query).await.unwrap_or_default()); + candidates +} + +/// Strip HTML tags from a provider description (Google Books returns light HTML) +/// and collapse whitespace, so the value is safe to store and show as plain text. +fn strip_html(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut in_tag = false; + for ch in input.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => out.push(ch), + _ => {} + } + } + out.split_whitespace().collect::>().join(" ") +} + +// ---- Google Books ---- + +#[derive(Deserialize)] +struct GbResponse { + #[serde(default)] + items: Vec, +} + +#[derive(Deserialize)] +struct GbItem { + #[serde(rename = "volumeInfo")] + volume_info: GbVolumeInfo, +} + +#[derive(Deserialize)] +struct GbVolumeInfo { + title: Option, + #[serde(default)] + authors: Vec, + #[serde(rename = "publishedDate")] + published_date: Option, + description: Option, + #[serde(rename = "imageLinks")] + image_links: Option, +} + +#[derive(Deserialize)] +struct GbImageLinks { + thumbnail: Option, + #[serde(rename = "smallThumbnail")] + small_thumbnail: Option, +} + +async fn google_books(client: &Client, query: &str) -> reqwest::Result> { + let resp: GbResponse = client + .get("https://www.googleapis.com/books/v1/volumes") + .query(&[("q", query), ("maxResults", "8")]) + .send() + .await? + .json() + .await?; + + Ok(resp + .items + .into_iter() + .map(|item| { + let v = item.volume_info; + // Google returns http thumbnails; the cover allowlist requires https. + let cover = v + .image_links + .and_then(|l| l.thumbnail.or(l.small_thumbnail)) + .map(|u| u.replacen("http://", "https://", 1)); + MetadataCandidate { + title: v.title, + authors: v.authors, + description: v + .description + .as_deref() + .map(strip_html) + .filter(|s| !s.is_empty()), + year: v.published_date.as_deref().and_then(parse_year), + cover_thumb_url: cover.clone(), + cover_full_url: cover, + source: "Google Books", + } + }) + .collect()) +} + +// ---- OpenLibrary ---- + +#[derive(Deserialize)] +struct OlSearch { + #[serde(default)] + docs: Vec, +} + +#[derive(Deserialize)] +struct OlDoc { + title: Option, + #[serde(default)] + author_name: Vec, + first_publish_year: Option, + cover_i: Option, +} + +async fn open_library(client: &Client, query: &str) -> reqwest::Result> { + let search: OlSearch = client + .get("https://openlibrary.org/search.json") + .query(&[ + ("q", query), + ("limit", "8"), + ("fields", "title,author_name,first_publish_year,cover_i"), + ]) + .send() + .await? + .json() + .await?; + + Ok(search + .docs + .into_iter() + .map(|doc| { + let cover_thumb = doc + .cover_i + .map(|id| format!("https://covers.openlibrary.org/b/id/{id}-M.jpg")); + let cover_full = doc + .cover_i + .map(|id| format!("https://covers.openlibrary.org/b/id/{id}-L.jpg")); + MetadataCandidate { + title: doc.title, + authors: doc.author_name, + // OpenLibrary search results carry no description (would need a + // per-work fetch); leave it for the user or a Google result. + description: None, + year: doc.first_publish_year, + cover_thumb_url: cover_thumb, + cover_full_url: cover_full, + source: "OpenLibrary", + } + }) + .collect()) +} + +/// Extract a 4-digit-ish year from a loose provider date string. +fn parse_year(s: &str) -> Option { + let year: String = s + .chars() + .skip_while(|c| !c.is_ascii_digit()) + .take_while(|c| c.is_ascii_digit()) + .collect(); + year.parse::().ok().filter(|y| *y > 0) +} + +#[cfg(test)] +mod tests { + use super::{parse_year, strip_html}; + + #[test] + fn strips_tags_and_collapses_whitespace() { + assert_eq!( + strip_html("

Hello world

\n
again"), + "Hello world again" + ); + } + + #[test] + fn plain_description_survives() { + assert_eq!(strip_html("Just plain text."), "Just plain text."); + } + + #[test] + fn parses_year_from_loose_dates() { + assert_eq!(parse_year("1949"), Some(1949)); + assert_eq!(parse_year("1949-06-08"), Some(1949)); + assert_eq!(parse_year("June 1949"), Some(1949)); + assert_eq!(parse_year("unknown"), None); + } +} diff --git a/src/upload/mod.rs b/src/upload/mod.rs index 87e89bc..8caa109 100644 --- a/src/upload/mod.rs +++ b/src/upload/mod.rs @@ -4,3 +4,4 @@ pub mod cover_search; pub mod enrich; pub mod epub; pub mod handler; +pub mod metadata_search; diff --git a/src/web.rs b/src/web.rs index 0535305..147fa6a 100644 --- a/src/web.rs +++ b/src/web.rs @@ -133,6 +133,7 @@ pub fn router(max_upload_size: usize) -> Router { ) .route("/api/books/{book_id}/sync", put(api_set_book_sync)) .route("/api/books/{book_id}/meta", put(api_set_book_meta)) + .route("/api/books/{book_id}/meta/search", get(api_search_meta)) .route("/api/books/{book_id}/cover/search", get(api_search_cover)) // PUT sets the cover from a chosen search-result URL; POST uploads a // custom image file (multipart, so it needs a body limit). @@ -455,6 +456,24 @@ struct CoverUrlReq { url: String, } +/// A candidate book record returned by the metadata-search endpoint. The UI +/// applies a chosen one to the edit form (and its `cover_full_url`, if any). +#[derive(Serialize)] +struct MetaCandidateJson { + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + authors: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + year: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cover_thumb_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cover_full_url: Option, + source: String, +} + /// GET /api/books/{book_id}/cover/search?q=… — Search online cover candidates. /// `q` defaults to the book's title when omitted. async fn api_search_cover( @@ -496,6 +515,50 @@ async fn api_search_cover( Ok(Json(candidates)) } +/// GET /api/books/{book_id}/meta/search?q=… — Search online providers for book +/// records to fill the edit form. `q` defaults to the book's title when omitted. +async fn api_search_meta( + auth_session: AuthSession, + State(state): State, + Path(book_id): Path, + Query(params): Query, +) -> Result>, (StatusCode, String)> { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + let book = state + .books + .fetch_book_by_id(book_id) + .await + .map_err(|e| { + error!("Failed to fetch book: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + })? + .ok_or((StatusCode::NOT_FOUND, "Book not found".to_string()))?; + + let query = if params.q.trim().is_empty() { + book.title + } else { + params.q + }; + + let candidates = crate::upload::metadata_search::search_metadata(&query) + .await + .into_iter() + .map(|c| MetaCandidateJson { + title: c.title, + authors: c.authors, + description: c.description, + year: c.year, + cover_thumb_url: c.cover_thumb_url, + cover_full_url: c.cover_full_url, + source: c.source.to_string(), + }) + .collect(); + Ok(Json(candidates)) +} + /// PUT /api/books/{book_id}/cover — Set the cover from an allowlisted image URL. async fn api_set_cover_url( auth_session: AuthSession, diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index f63a381..ee61d76 100644 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -188,6 +188,33 @@ pub async fn set_book_sync(id: i64, enabled: bool) -> ApiResult<()> { check(resp).await.map(|_| ()) } +/// A candidate book record from `GET /api/books/{id}/meta/search`, applied to +/// the edit form when picked. +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct MetaCandidate { + #[serde(default)] + pub title: Option, + #[serde(default)] + pub authors: Vec, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub year: Option, + #[serde(default)] + pub cover_thumb_url: Option, + #[serde(default)] + pub cover_full_url: Option, + pub source: String, +} + +/// `GET /api/books/{id}/meta/search?q=…` — candidate records for a book. +pub async fn search_metadata(id: i64, query: &str) -> ApiResult> { + let encoded = String::from(js_sys::encode_uri_component(query)); + let url = format!("/api/books/{id}/meta/search?q={encoded}"); + let resp = gloo_net::http::Request::get(&url).send().await?; + Ok(check(resp).await?.json().await?) +} + /// Editable book metadata sent to `PUT /api/books/{id}/meta`. Blank fields /// clear the corresponding override on the server. #[derive(Debug, Clone, Serialize)] diff --git a/web-ui/src/components/book_editor.rs b/web-ui/src/components/book_editor.rs index fbb0f0c..6ffc10d 100644 --- a/web-ui/src/components/book_editor.rs +++ b/web-ui/src/components/book_editor.rs @@ -12,7 +12,7 @@ use wasm_bindgen::JsCast; use wasm_bindgen_futures::spawn_local; use web_sys::{FormData, HtmlInputElement}; -use crate::api::{self, ApiResult, Book, BookMeta, CoverCandidate}; +use crate::api::{self, ApiResult, Book, BookMeta, CoverCandidate, MetaCandidate}; use crate::style::{ ALERT_ERROR, ALERT_INFO, ALERT_SUCCESS, BTN, BTN_PRIMARY, BTN_SM, INPUT, LABEL, SPINNER, }; @@ -26,6 +26,10 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In let series_index = RwSignal::new(String::new()); let description = RwSignal::new(String::new()); + // ---- metadata search state ---- + let meta_query = RwSignal::new(String::new()); + let meta_results = RwSignal::new(None::>>); + // ---- cover state ---- let query = RwSignal::new(String::new()); let results = RwSignal::new(None::>>); @@ -45,6 +49,13 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In series.set(b.series.clone().unwrap_or_default()); series_index.set(b.series_index.map(format_index).unwrap_or_default()); description.set(b.description.clone().unwrap_or_default()); + let default_query = if b.authors.is_empty() { + b.title.clone() + } else { + format!("{} {}", b.title, b.authors.join(" ")) + }; + meta_query.set(default_query); + meta_results.set(None); query.set(b.title.clone()); results.set(None); cover_bust.set(b.cover_version); @@ -88,6 +99,70 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In }); }; + // Search online providers for records matching the metadata query. + let do_meta_search = move || { + let Some(b) = edit.get_untracked() else { + return; + }; + let q = meta_query.get_untracked(); + meta_results.set(None); + busy.set(true); + spawn_local(async move { + let r = api::search_metadata(b.id, &q).await; + meta_results.set(Some(r)); + busy.set(false); + }); + }; + + // Apply a chosen record to the form: fill each field the record supplies + // (leaving the rest untouched) and, if it carries a cover, set that too. + // Nothing is saved yet — the user reviews and clicks Save. + let apply_meta = move |c: MetaCandidate| { + if let Some(t) = c.title.filter(|s| !s.trim().is_empty()) { + title.set(t); + } + if !c.authors.is_empty() { + authors.set(c.authors.join(", ")); + } + if let Some(d) = c.description.filter(|s| !s.trim().is_empty()) { + description.set(d); + } + let source = c.source.clone(); + if let Some(url) = c.cover_full_url { + let Some(b) = edit.get_untracked() else { + return; + }; + busy.set(true); + status.set(Some(( + format!("Filled from {source} \u{2022} updating cover\u{2026}"), + ALERT_INFO, + ))); + spawn_local(async move { + match api::set_cover_url(b.id, &url).await { + Ok(()) => { + has_cover.set(true); + cover_bust.update(|v| *v += 1); + status.set(Some(( + format!("Filled from {source}. Review and Save."), + ALERT_SUCCESS, + ))); + reload.run(()); + } + Err(err) => status.set(Some(( + format!("Filled fields; cover failed: {err}"), + ALERT_ERROR, + ))), + } + busy.set(false); + }); + } else { + status.set(Some(( + format!("Filled from {source}. Review and Save."), + ALERT_SUCCESS, + ))); + } + }; + let do_search = move || { let Some(b) = edit.get_untracked() else { return; @@ -230,6 +305,75 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In + // ---- metadata search ---- +
+ +
+ + +
+
+ {move || match meta_results.get() { + None => if busy.get() { + view! {
}.into_any() + } else { + view! {

+ "Search to fill the fields above from OpenLibrary / Google Books." +

}.into_any() + }, + Some(Err(err)) => view! { +

{format!("Search failed: {err}")}

+ }.into_any(), + Some(Ok(list)) if list.is_empty() => view! { +

"No matches found."

+ }.into_any(), + Some(Ok(list)) => { + let rows = list.into_iter().map(|c| { + let cand = c.clone(); + let title_txt = c.title.clone().unwrap_or_else(|| "Untitled".to_string()); + let authors_txt = if c.authors.is_empty() { + String::new() + } else { + c.authors.join(", ") + }; + let year_txt = c.year.map(|y| format!(" \u{2022} {y}")).unwrap_or_default(); + let has_desc = c.description.as_ref().is_some_and(|d| !d.trim().is_empty()); + view! { + + } + }).collect_view(); + view! {
{rows}
}.into_any() + } + }} +
+
+ // ---- cover search ----
-- 2.51.2 From f42d1e5bbd4049af0f59e4bf14ff0b51c544babd Mon Sep 17 00:00:00 2001 From: servius Date: Mon, 27 Jul 2026 22:12:39 +0530 Subject: [PATCH 3/6] fix: show only the active search's spinner in the book editor The metadata and cover searches shared the global busy flag, so both spinners appeared when either search ran. Give each its own loading flag. --- web-ui/src/components/book_editor.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/web-ui/src/components/book_editor.rs b/web-ui/src/components/book_editor.rs index 6ffc10d..84a5b5b 100644 --- a/web-ui/src/components/book_editor.rs +++ b/web-ui/src/components/book_editor.rs @@ -29,6 +29,8 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In // ---- metadata search state ---- let meta_query = RwSignal::new(String::new()); let meta_results = RwSignal::new(None::>>); + // Drives only the metadata spinner (`busy` is shared, so it can't). + let meta_loading = RwSignal::new(false); // ---- cover state ---- let query = RwSignal::new(String::new()); @@ -36,6 +38,8 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In // Preview cache-buster: `?v=` value for the cover img, bumped on each change. let cover_bust = RwSignal::new(0i64); let has_cover = RwSignal::new(false); + // Drives only the cover spinner. + let cover_loading = RwSignal::new(false); let status = RwSignal::new(None::<(String, &'static str)>); let busy = RwSignal::new(false); @@ -56,8 +60,10 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In }; meta_query.set(default_query); meta_results.set(None); + meta_loading.set(false); query.set(b.title.clone()); results.set(None); + cover_loading.set(false); cover_bust.set(b.cover_version); has_cover.set(b.has_cover); status.set(None); @@ -106,10 +112,12 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In }; let q = meta_query.get_untracked(); meta_results.set(None); + meta_loading.set(true); busy.set(true); spawn_local(async move { let r = api::search_metadata(b.id, &q).await; meta_results.set(Some(r)); + meta_loading.set(false); busy.set(false); }); }; @@ -169,10 +177,12 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In }; let q = query.get_untracked(); results.set(None); + cover_loading.set(true); busy.set(true); spawn_local(async move { let r = api::search_covers(b.id, &q).await; results.set(Some(r)); + cover_loading.set(false); busy.set(false); }); }; @@ -320,7 +330,7 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In
{move || match meta_results.get() { - None => if busy.get() { + None => if meta_loading.get() { view! {
}.into_any() } else { view! {

@@ -389,7 +399,7 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In

{move || match results.get() { - None => if busy.get() { + None => if cover_loading.get() { view! {
}.into_any() } else { view! {

-- 2.51.2 From 0b8b74037a617be8e7fdc2dc67c1787633a6912c Mon Sep 17 00:00:00 2001 From: servius Date: Mon, 27 Jul 2026 22:28:18 +0530 Subject: [PATCH 4/6] feat: use pen and trashcan icons for the book edit/delete buttons Replace the 'Edit' text with a pencil (new ico-pen mask utility) and the 'x' delete glyph with the existing ico-trash icon. --- web-ui/src/components/library.rs | 14 ++++++++------ web-ui/tailwind.css | 8 ++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/web-ui/src/components/library.rs b/web-ui/src/components/library.rs index e1a467f..d2abcae 100644 --- a/web-ui/src/components/library.rs +++ b/web-ui/src/components/library.rs @@ -347,7 +347,8 @@ pub fn Library() -> impl IntoView {

{is_upload.then(|| view! { })}
{title.clone()} }.into_any() }} -

{title.clone()}

diff --git a/web-ui/tailwind.css b/web-ui/tailwind.css index adfc7c7..4fd7004 100644 --- a/web-ui/tailwind.css +++ b/web-ui/tailwind.css @@ -76,3 +76,11 @@ -webkit-mask: var(--ico) center / contain no-repeat; mask: var(--ico) center / contain no-repeat; } + +/* Pencil/edit glyph as a mask (Feather "edit-2"). Use as + `class="w-4 h-4 bg-current ico-pen"`. */ +@utility ico-pen { + --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z'/%3E%3C/svg%3E"); + -webkit-mask: var(--ico) center / contain no-repeat; + mask: var(--ico) center / contain no-repeat; +} -- 2.51.2 From af297f83f296a0e43bf836f6fea9c3bbdb0509fb Mon Sep 17 00:00:00 2001 From: servius Date: Mon, 27 Jul 2026 22:43:57 +0530 Subject: [PATCH 5/6] feat: soft-delete books with a Trash view (restore / permanent purge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card trash button now soft-deletes any book instead of hard-deleting uploads: a row in a new `deleted_books` table hides it from the library and drops it from every device on the next sync, while keeping the book row and its files. Kept in its own table (keyed by the preserved books.id) so it survives the Calibre re-ingest — the only way to keep a Calibre book hidden, since its row is rebuilt from the read-only metadata.db. A "Deleted" library filter shows the trash: Restore any book, or (uploads only) Delete permanently, which purges the files via the existing /api/upload/{id} path and clears the soft-delete marker. Routes: DELETE /api/books/{id} (soft), POST /api/books/{id}/restore. list_catalog exposes `deleted`; the syncable query excludes deleted books. --- src/db/app_db.rs | 10 ++ src/db/book_store.rs | 42 +++++++- src/web.rs | 44 +++++++++ web-ui/src/api.rs | 20 ++++ web-ui/src/components/library.rs | 159 ++++++++++++++++++++----------- 5 files changed, 219 insertions(+), 56 deletions(-) diff --git a/src/db/app_db.rs b/src/db/app_db.rs index e4d3654..7ab9141 100644 --- a/src/db/app_db.rs +++ b/src/db/app_db.rs @@ -378,6 +378,16 @@ impl AppDb { series_index REAL, description TEXT )"#, + // Soft-deleted books. A present row hides the book from the catalog + // and from every user's sync (removed from the device on next sync), + // without dropping the `books` row or its files. Kept in its own + // table (keyed by the preserved `books.id`) so it survives the + // startup Calibre re-ingest — the only way to keep a Calibre book + // hidden, since its row is rebuilt from the read-only metadata.db. + r#"CREATE TABLE IF NOT EXISTS deleted_books ( + book_id INTEGER NOT NULL PRIMARY KEY, + deleted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + )"#, ]; for sql in &statements { diff --git a/src/db/book_store.rs b/src/db/book_store.rs index b74622b..066901a 100644 --- a/src/db/book_store.rs +++ b/src/db/book_store.rs @@ -122,6 +122,9 @@ pub struct CatalogBook { /// Override-cover version (0 = none). Bumped each time the cover is replaced; /// used as a `/covers/{id}?v=` cache-buster in the web UI. pub cover_version: i64, + /// Whether the book is soft-deleted (hidden from the library + all syncs, + /// files retained). Shown only in the web UI's "Deleted" view. + pub deleted: bool, } /// The full `books` row, as read back from the app DB. @@ -236,11 +239,13 @@ impl BookStore { b.file_format, b.source, b.timestamp, (b.has_cover != 0 OR c.book_id IS NOT NULL) AS has_cover, COALESCE(c.version, 0) AS cover_version, - COALESCE(p.enabled, b.default_sync) AS sync_enabled + COALESCE(p.enabled, b.default_sync) AS sync_enabled, + (x.book_id IS NOT NULL) AS deleted FROM books b LEFT JOIN book_sync_prefs p ON p.book_id = b.id AND p.user_id = ?1 LEFT JOIN book_covers c ON c.book_id = b.id LEFT JOIN book_meta_overrides o ON o.book_id = b.id + LEFT JOIN deleted_books x ON x.book_id = b.id ORDER BY title COLLATE NOCASE ASC "#, ) @@ -281,6 +286,7 @@ impl BookStore { .and_then(parse_dt) .unwrap_or_else(Utc::now), cover_version: r.get::("cover_version"), + deleted: r.get::, _>("deleted").unwrap_or(0) != 0, } }) .collect()) @@ -319,6 +325,7 @@ impl BookStore { LEFT JOIN book_sync_prefs p ON p.book_id = b.id AND p.user_id = ?1 LEFT JOIN book_covers c ON c.book_id = b.id WHERE COALESCE(p.enabled, b.default_sync) = 1 + AND b.id NOT IN (SELECT book_id FROM deleted_books) ORDER BY b.id ASC "#, ) @@ -551,9 +558,42 @@ impl BookStore { .await .change_context(Error::Database) .attach("Failed to delete uploaded book")?; + // Drop any soft-delete marker so a later re-upload of the same id-space + // does not inherit a stale "deleted" flag. + sqlx::query("DELETE FROM deleted_books WHERE book_id = ?") + .bind(id) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to clear soft-delete marker")?; Ok(Some(locator)) } + /// Soft-delete a book: hide it from the catalog and from every user's sync + /// (removed from the device on next sync), keeping its row and files. Global + /// (not per-user), consistent with cover/metadata edits. Idempotent. + pub async fn soft_delete_book(&self, book_id: i64) -> Result<()> { + sqlx::query("INSERT OR IGNORE INTO deleted_books (book_id) VALUES (?)") + .bind(book_id) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to soft-delete book")?; + Ok(()) + } + + /// Restore a soft-deleted book (removes its `deleted_books` marker). + /// Idempotent. + pub async fn restore_book(&self, book_id: i64) -> Result<()> { + sqlx::query("DELETE FROM deleted_books WHERE book_id = ?") + .bind(book_id) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to restore book")?; + Ok(()) + } + /// Find an uploaded book by owner + content hash (re-upload dedupe). pub async fn find_by_hash(&self, owner_user_id: i64, hash: &str) -> Result> { let row = sqlx::query( diff --git a/src/web.rs b/src/web.rs index 147fa6a..9ed4816 100644 --- a/src/web.rs +++ b/src/web.rs @@ -55,6 +55,9 @@ struct BookJson { /// Override-cover version (0 = none). The UI appends it as `/covers/{id}?v=` /// to bust the browser cache when the cover changes. cover_version: i64, + /// Whether the book is soft-deleted (shown only in the UI's "Deleted" view; + /// hidden from normal views and from all device syncs). + deleted: bool, } /// JSON representation of the current user. @@ -132,6 +135,8 @@ pub fn router(max_upload_size: usize) -> Router { get(api_get_settings).put(api_update_settings), ) .route("/api/books/{book_id}/sync", put(api_set_book_sync)) + .route("/api/books/{book_id}", delete(api_soft_delete_book)) + .route("/api/books/{book_id}/restore", post(api_restore_book)) .route("/api/books/{book_id}/meta", put(api_set_book_meta)) .route("/api/books/{book_id}/meta/search", get(api_search_meta)) .route("/api/books/{book_id}/cover/search", get(api_search_cover)) @@ -241,6 +246,7 @@ async fn api_books( sync_enabled: b.sync_enabled, timestamp: b.timestamp.to_rfc3339(), cover_version: b.cover_version, + deleted: b.deleted, }) .collect(); @@ -323,6 +329,44 @@ async fn api_set_book_sync( Ok(StatusCode::NO_CONTENT) } +/// DELETE /api/books/{book_id} — Soft-delete a book: hide it from the library +/// and remove it from every device on next sync, keeping its row and files so it +/// can be restored. Global (any logged-in user), like cover/metadata edits. +async fn api_soft_delete_book( + auth_session: AuthSession, + State(state): State, + Path(book_id): Path, +) -> Result { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + ensure_book_exists(&state, book_id).await?; + + state.books.soft_delete_book(book_id).await.map_err(|e| { + error!("Failed to soft-delete book: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + })?; + Ok(StatusCode::NO_CONTENT) +} + +/// POST /api/books/{book_id}/restore — Undo a soft delete. +async fn api_restore_book( + auth_session: AuthSession, + State(state): State, + Path(book_id): Path, +) -> Result { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + ensure_book_exists(&state, book_id).await?; + + state.books.restore_book(book_id).await.map_err(|e| { + error!("Failed to restore book: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + })?; + Ok(StatusCode::NO_CONTENT) +} + /// PUT /api/books/{book_id}/meta — Update a book's editable metadata (title, /// authors, series, description). Stored as a per-book override so Calibre /// re-ingest does not clobber it; blank fields clear the override. Reflected in diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index ee61d76..3a4fd11 100644 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -35,6 +35,9 @@ pub struct Book { /// Override-cover version (0 = none). Used as `/covers/{id}?v=` to bust the /// browser cache after the cover changes. pub cover_version: i64, + /// Whether the book is soft-deleted (shown only in the "Deleted" view). + #[serde(default)] + pub deleted: bool, } /// A candidate cover from `GET /api/books/{id}/cover/search`. @@ -312,6 +315,23 @@ pub async fn register_device(token: &str) -> ApiResult<()> { check(resp).await.map(|_| ()) } +/// `DELETE /api/books/{id}` — soft-delete a book (hidden + dropped from sync, +/// row and files kept, restorable). +pub async fn delete_book(id: i64) -> ApiResult<()> { + let resp = gloo_net::http::Request::delete(&format!("/api/books/{id}")) + .send() + .await?; + check(resp).await.map(|_| ()) +} + +/// `POST /api/books/{id}/restore` — undo a soft delete. +pub async fn restore_book(id: i64) -> ApiResult<()> { + let resp = gloo_net::http::Request::post(&format!("/api/books/{id}/restore")) + .send() + .await?; + check(resp).await.map(|_| ()) +} + /// `POST /api/upload` — upload an EPUB via multipart form data. pub async fn upload(form: FormData) -> ApiResult { let resp = gloo_net::http::Request::post("/api/upload") diff --git a/web-ui/src/components/library.rs b/web-ui/src/components/library.rs index d2abcae..fecd290 100644 --- a/web-ui/src/components/library.rs +++ b/web-ui/src/components/library.rs @@ -29,7 +29,9 @@ enum Pending { /// Confirmed delete of a device-bound token (retries with `force`). The /// device serial is already in the prompt text stored alongside it. ForceDeleteToken(i64), - DeleteBook(i64), + /// Permanently delete an uploaded book (purge files); only reachable from the + /// "Deleted" view. Soft delete itself is one-click and needs no confirm. + PurgeBook(i64), } /// How the library grid is ordered. @@ -43,7 +45,8 @@ enum Sort { Sync, } -/// Which books the grid shows, by per-user sync state. +/// Which books the grid shows, by per-user sync state. All views except +/// [`Filter::Deleted`] hide soft-deleted books. #[derive(Clone, Copy, PartialEq)] enum Filter { /// Every book in the catalog. @@ -52,6 +55,8 @@ enum Filter { Synced, /// Only books that do not sync to this user's devices. Unsynced, + /// Only soft-deleted books (the "trash"), with restore/purge controls. + Deleted, } #[component] @@ -268,11 +273,13 @@ pub fn Library() -> impl IntoView { on:change=move |ev| filter.set(match event_target_value(&ev).as_str() { "synced" => Filter::Synced, "unsynced" => Filter::Unsynced, + "deleted" => Filter::Deleted, _ => Filter::All, })> + + + +
-

{title.clone()}

-

{author}

- -
+ }.into_any() } }).collect_view(); view! { @@ -445,7 +494,7 @@ pub fn Library() -> impl IntoView { let _ = api::delete_token(id, true).await; reload_tokens(); }), - Pending::DeleteBook(id) => spawn_local(async move { + Pending::PurgeBook(id) => spawn_local(async move { let _ = api::delete_upload(id).await; reload_books(); }), -- 2.51.2 From 4d0f63802b362ac5df2c651fb80f01813a01c6b2 Mon Sep 17 00:00:00 2001 From: servius Date: Mon, 27 Jul 2026 23:01:05 +0530 Subject: [PATCH 6/6] feat: reset a book's cover to its original (remove custom cover) Add a "Reset to default" control to the Edit modal (shown only when a custom cover is set). It calls the new DELETE /api/books/{id}/cover, which removes the override file and clears the book_covers row, so cover_path falls back to the source Calibre/upload cover on both the web and the device. Bumps books.last_modified so the device re-syncs to the bare-uuid CoverImageId. --- src/db/app_db.rs | 25 ++++++++++++++++ src/web.rs | 27 +++++++++++++++++ web-ui/src/api.rs | 9 ++++++ web-ui/src/components/book_editor.rs | 44 ++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+) diff --git a/src/db/app_db.rs b/src/db/app_db.rs index 7ab9141..ed9d564 100644 --- a/src/db/app_db.rs +++ b/src/db/app_db.rs @@ -1177,6 +1177,31 @@ impl AppDb { Ok(version) } + /// Drop a book's override-cover record so it reverts to its source (Calibre / + /// upload) cover. The caller removes the override file; this clears the + /// `book_covers` row (so `cover_version` returns to 0 and `has_cover` reflects + /// the source again) and bumps `books.last_modified` so the next device sync + /// re-emits the entitlement with the bare-uuid `CoverImageId`. + pub async fn remove_book_cover(&self, book_id: i64) -> Result<()> { + sqlx::query("DELETE FROM book_covers WHERE book_id = ?") + .bind(book_id) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to clear book cover override")?; + + sqlx::query( + "UPDATE books SET last_modified = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", + ) + .bind(book_id) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to bump book last_modified after cover reset")?; + + Ok(()) + } + /// Store per-book metadata overrides (title, authors, series, description). /// Each argument replaces the source value on read; passing `None` clears /// that field's override so it falls back to the Calibre/upload value. The diff --git a/src/web.rs b/src/web.rs index 9ed4816..a0dd4cb 100644 --- a/src/web.rs +++ b/src/web.rs @@ -146,6 +146,7 @@ pub fn router(max_upload_size: usize) -> Router { "/api/books/{book_id}/cover", put(api_set_cover_url) .post(api_upload_cover) + .delete(api_reset_cover) .layer(DefaultBodyLimit::max(20 * 1024 * 1024)), ) .route("/covers/{book_id}", get(cover)) @@ -663,6 +664,32 @@ async fn api_upload_cover( store_override_cover(&state, book_id, raw).await } +/// DELETE /api/books/{book_id}/cover — Remove the override cover so the book +/// reverts to its source (Calibre / upload) cover, on both the web and the +/// device. Removes the override file and clears the cover record. Idempotent. +async fn api_reset_cover( + auth_session: AuthSession, + State(state): State, + Path(book_id): Path, +) -> Result { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + ensure_book_exists(&state, book_id).await?; + + let path = state.books.cover_override_path(book_id); + if let Err(e) = tokio::fs::remove_file(&path).await { + if e.kind() != std::io::ErrorKind::NotFound { + error!("Failed to remove override cover for {book_id}: {e}"); + } + } + state.app_db.remove_book_cover(book_id).await.map_err(|e| { + error!("Failed to reset cover: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + })?; + Ok(StatusCode::NO_CONTENT) +} + /// 404 unless a book with `book_id` exists. async fn ensure_book_exists(state: &AppState, book_id: i64) -> Result<(), (StatusCode, String)> { let exists = state diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index 3a4fd11..9a12e37 100644 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -264,6 +264,15 @@ pub async fn upload_cover(id: i64, form: FormData) -> ApiResult<()> { check(resp).await.map(|_| ()) } +/// `DELETE /api/books/{id}/cover` — drop the custom cover, reverting to the +/// book's original Calibre/upload cover. +pub async fn reset_cover(id: i64) -> ApiResult<()> { + let resp = gloo_net::http::Request::delete(&format!("/api/books/{id}/cover")) + .send() + .await?; + check(resp).await.map(|_| ()) +} + /// `GET /api/tokens` — the user's Kobo auth tokens. pub async fn tokens() -> ApiResult> { let resp = gloo_net::http::Request::get("/api/tokens").send().await?; diff --git a/web-ui/src/components/book_editor.rs b/web-ui/src/components/book_editor.rs index 84a5b5b..500a602 100644 --- a/web-ui/src/components/book_editor.rs +++ b/web-ui/src/components/book_editor.rs @@ -38,6 +38,9 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In // Preview cache-buster: `?v=` value for the cover img, bumped on each change. let cover_bust = RwSignal::new(0i64); let has_cover = RwSignal::new(false); + // Whether a custom (override) cover is set — gates the "Reset to default" + // control. Starts from the book's cover_version (>0 ⇒ override present). + let has_override = RwSignal::new(false); // Drives only the cover spinner. let cover_loading = RwSignal::new(false); @@ -66,6 +69,7 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In cover_loading.set(false); cover_bust.set(b.cover_version); has_cover.set(b.has_cover); + has_override.set(b.cover_version > 0); status.set(None); busy.set(false); } @@ -150,6 +154,7 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In Ok(()) => { has_cover.set(true); cover_bust.update(|v| *v += 1); + has_override.set(true); status.set(Some(( format!("Filled from {source}. Review and Save."), ALERT_SUCCESS, @@ -200,6 +205,7 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In Ok(()) => { has_cover.set(true); cover_bust.update(|v| *v += 1); + has_override.set(true); results.set(None); status.set(Some(("Cover updated.".to_string(), ALERT_SUCCESS))); reload.run(()); @@ -239,6 +245,7 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In Ok(()) => { has_cover.set(true); cover_bust.update(|v| *v += 1); + has_override.set(true); status.set(Some(("Cover updated.".to_string(), ALERT_SUCCESS))); reload.run(()); } @@ -249,6 +256,35 @@ pub fn BookEditor(edit: RwSignal>, reload: Callback<()>) -> impl In }); }; + // Drop the override cover, reverting to the source (Calibre/upload) cover. + let reset_cover = move || { + let Some(b) = edit.get_untracked() else { + return; + }; + busy.set(true); + status.set(Some(( + "Restoring default cover\u{2026}".to_string(), + ALERT_INFO, + ))); + spawn_local(async move { + match api::reset_cover(b.id).await { + Ok(()) => { + has_override.set(false); + cover_bust.update(|v| *v += 1); + status.set(Some(( + "Restored the default cover.".to_string(), + ALERT_SUCCESS, + ))); + reload.run(()); + } + Err(err) => { + status.set(Some((format!("Failed to reset cover: {err}"), ALERT_ERROR))) + } + } + busy.set(false); + }); + }; + view! { {move || edit.get().map(|b| { let book_id = b.id; view! {
>, reload: Callback<()>) -> impl In prop:disabled=move || busy.get()> "Upload image" + {move || has_override.get().then(|| view! { + + })}
// ---- metadata column ----