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 ----