From b55b3bebe6ac0b30f0a15b74c9287b9f1e51239c Mon Sep 17 00:00:00 2001 From: Phil Denhoff Date: Sun, 2 Aug 2026 02:15:20 -0400 Subject: [PATCH] feat(opds): add catalog navigation and search --- crates/citadel-opds/src/catalog.rs | 683 ++++++++++++++++-- crates/citadel-opds/src/identity.rs | 8 + crates/citadel-opds/src/lib.rs | 4 +- crates/citadel-opds/src/service.rs | 3 +- crates/citadel-server/src/lib.rs | 65 +- .../citadel-server/tests/headless_process.rs | 12 +- crates/libcalibre/src/lib.rs | 6 +- crates/libcalibre/src/library.rs | 85 ++- crates/libcalibre/src/queries/authors.rs | 39 +- crates/libcalibre/src/queries/books.rs | 59 +- crates/libcalibre/src/queries/tags.rs | 40 +- crates/libcalibre/tests/query_test.rs | 157 ++++ ...k_test__add_single_book_full_database.snap | 26 +- src-tauri/src/libs/calibre/query.rs | 2 + src-tauri/src/state.rs | 77 +- 15 files changed, 1136 insertions(+), 130 deletions(-) diff --git a/crates/citadel-opds/src/catalog.rs b/crates/citadel-opds/src/catalog.rs index bb0c7f06..9d0cb7ff 100644 --- a/crates/citadel-opds/src/catalog.rs +++ b/crates/citadel-opds/src/catalog.rs @@ -10,31 +10,109 @@ use axum::{ }; use bytes::Bytes; use chrono::{NaiveDateTime, SecondsFormat}; -use libcalibre::{BookId, BookPage, CalibreError, ResolvedBookAsset}; +use libcalibre::{ + AuthorId, BookId, BookPage, BookQuery, BookSortOrder, CalibreError, ResolvedBookAsset, +}; use serde::Deserialize; use super::{ assets::{self, AssetMethod, AssetResponseError}, auth::{require_basic_auth, OpdsBasicAuth}, }; -use crate::identity::{book_identity, library_identity}; +use crate::identity::{book_identity, library_identity, navigation_identity}; const PAGE_SIZE: u64 = 50; +const MAX_SEARCH_LENGTH: usize = 200; const ACQUISITION_REL: &str = "http://opds-spec.org/acquisition"; pub(crate) const IMAGE_REL: &str = "http://opds-spec.org/image"; const ATOM_TYPE: &str = "application/atom+xml;profile=opds-catalog;kind=acquisition"; const ATOM_CONTENT_TYPE: &str = "application/atom+xml;profile=opds-catalog;kind=acquisition; charset=utf-8"; +const NAVIGATION_TYPE: &str = "application/atom+xml;profile=opds-catalog;kind=navigation"; +const NAVIGATION_CONTENT_TYPE: &str = + "application/atom+xml;profile=opds-catalog;kind=navigation; charset=utf-8"; +const OPENSEARCH_TYPE: &str = "application/opensearchdescription+xml"; pub(crate) const IMAGE_TYPE: &str = "image/jpeg"; +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CatalogFilter { + All, + Unread, + Author(i32), + Series(i32), + Tag(i32), + Genre(i32), + Search(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CatalogSort { + Title, + Updated, + SeriesIndex, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CatalogBookQuery { + pub filter: CatalogFilter, + pub sort: CatalogSort, + pub limit: i64, + pub offset: i64, +} + +impl CatalogBookQuery { + /// Translate the protocol query into libcalibre's bounded query contract. + /// Genre becomes available when CDL-30 adds its canonical library filter. + pub fn into_calibre(self) -> Option { + let mut query = BookQuery { + limit: Some(self.limit), + offset: self.offset, + sort: match self.sort { + CatalogSort::Title => BookSortOrder::TitleAsc, + CatalogSort::Updated => BookSortOrder::UpdatedDesc, + CatalogSort::SeriesIndex => BookSortOrder::SeriesIndexAsc, + }, + ..BookQuery::default() + }; + match self.filter { + CatalogFilter::All => {} + CatalogFilter::Unread => query.hide_read = true, + CatalogFilter::Author(id) => query.author_id = Some(AuthorId(id)), + CatalogFilter::Series(id) => query.series_id = Some(id), + CatalogFilter::Tag(id) => query.tag_id = Some(id), + CatalogFilter::Genre(_) => return None, + CatalogFilter::Search(text) => query.text = Some(text), + } + Some(query) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CatalogFacet { + pub id: i32, + pub title: String, + pub book_count: Option, +} + pub trait CatalogSource: Send + Sync + 'static { fn active_library_id(&self) -> Result; fn book_page( &self, - limit: i64, - offset: i64, + query: CatalogBookQuery, ) -> Result<(String, Option, BookPage), CalibreError>; + fn authors(&self) -> Result, CalibreError> { + Ok(Vec::new()) + } + fn series(&self) -> Result, CalibreError> { + Ok(Vec::new()) + } + fn tags(&self) -> Result, CalibreError> { + Ok(Vec::new()) + } + fn genres(&self) -> Result, CalibreError> { + Ok(Vec::new()) + } fn book_file(&self, book_id: BookId, format: &str) -> Result; fn book_cover(&self, book_id: BookId) -> Result; } @@ -81,6 +159,18 @@ pub fn router(source: Arc, auth: OpdsBasicAuth) -> Router { Router::new() .route("/opds", get(root_feed)) .route("/opds/all", get(all_books_feed)) + .route("/opds/recent", get(recent_books_feed)) + .route("/opds/unread", get(unread_books_feed)) + .route("/opds/authors", get(authors_feed)) + .route("/opds/authors/{id}", get(author_books_feed)) + .route("/opds/series", get(series_feed)) + .route("/opds/series/{id}", get(series_books_feed)) + .route("/opds/tags", get(tags_feed)) + .route("/opds/tags/{id}", get(tag_books_feed)) + .route("/opds/genres", get(genres_feed)) + .route("/opds/genres/{id}", get(genre_books_feed)) + .route("/opds/search", get(search_feed)) + .route("/opds/opensearch.xml", get(opensearch_description)) .route( "/opds/books/{book_id}/files/{format}/{filename}", get(book_file).head(book_file_head), @@ -97,17 +187,175 @@ pub fn router(source: Arc, auth: OpdsBasicAuth) -> Router { } async fn root_feed(state: State, query: Query) -> Response { - feed(state, query, "/opds").await + if query.page.unwrap_or(1) != 1 { + return public_error(StatusCode::NOT_FOUND, "Page not found"); + } + let source = state.source.clone(); + let library_uuid = match tokio::task::spawn_blocking(move || source.active_library_id()).await { + Ok(Ok(uuid)) => uuid, + Ok(Err(error)) => return calibre_error(error), + Err(_) => return public_error(StatusCode::INTERNAL_SERVER_ERROR, "Catalog unavailable"), + }; + match root_navigation_feed(&library_uuid) { + Ok(xml) => xml_response(NAVIGATION_CONTENT_TYPE, xml), + Err(_) => public_error(StatusCode::INTERNAL_SERVER_ERROR, "Catalog unavailable"), + } } async fn all_books_feed(state: State, query: Query) -> Response { - feed(state, query, "/opds/all").await + acquisition_handler( + state, + query.0, + CatalogFilter::All, + CatalogSort::Title, + "/opds/all", + "All Books", + ) + .await +} + +async fn recent_books_feed(state: State, query: Query) -> Response { + acquisition_handler( + state, + query.0, + CatalogFilter::All, + CatalogSort::Updated, + "/opds/recent", + "Recently Modified", + ) + .await +} + +async fn unread_books_feed(state: State, query: Query) -> Response { + acquisition_handler( + state, + query.0, + CatalogFilter::Unread, + CatalogSort::Title, + "/opds/unread", + "Unread", + ) + .await +} + +async fn author_books_feed( + state: State, + Path(id): Path, + query: Query, +) -> Response { + acquisition_handler( + state, + query.0, + CatalogFilter::Author(id), + CatalogSort::Title, + &format!("/opds/authors/{id}"), + "Books by Author", + ) + .await +} + +async fn series_books_feed( + state: State, + Path(id): Path, + query: Query, +) -> Response { + acquisition_handler( + state, + query.0, + CatalogFilter::Series(id), + CatalogSort::SeriesIndex, + &format!("/opds/series/{id}"), + "Books in Series", + ) + .await +} + +async fn tag_books_feed( + state: State, + Path(id): Path, + query: Query, +) -> Response { + acquisition_handler( + state, + query.0, + CatalogFilter::Tag(id), + CatalogSort::Title, + &format!("/opds/tags/{id}"), + "Books by Tag", + ) + .await +} + +async fn genre_books_feed( + state: State, + Path(id): Path, + query: Query, +) -> Response { + acquisition_handler( + state, + query.0, + CatalogFilter::Genre(id), + CatalogSort::Title, + &format!("/opds/genres/{id}"), + "Books by Genre", + ) + .await +} + +#[derive(Deserialize)] +struct SearchQuery { + q: Option, + page: Option, } -async fn feed( +async fn search_feed(state: State, Query(query): Query) -> Response { + let text = query.q.unwrap_or_default(); + let text = text.trim(); + if text.is_empty() { + return public_error(StatusCode::BAD_REQUEST, "Search query is required"); + } + if text.chars().count() > MAX_SEARCH_LENGTH { + return public_error(StatusCode::BAD_REQUEST, "Search query is too long"); + } + let route = format!("/opds/search?q={}", urlencoding::encode(text)); + acquisition_handler( + state, + PageQuery { page: query.page }, + CatalogFilter::Search(text.to_string()), + CatalogSort::Title, + &route, + "Search Results", + ) + .await +} + +async fn authors_feed(state: State) -> Response { + facet_handler(state, CatalogFacetKind::Authors).await +} + +async fn series_feed(state: State) -> Response { + facet_handler(state, CatalogFacetKind::Series).await +} + +async fn tags_feed(state: State) -> Response { + facet_handler(state, CatalogFacetKind::Tags).await +} + +async fn genres_feed(state: State) -> Response { + facet_handler(state, CatalogFacetKind::Genres).await +} + +async fn opensearch_description() -> Response { + xml_response(OPENSEARCH_TYPE, opensearch_xml()) +} + +async fn acquisition_handler( State(state): State, - Query(query): Query, - route: &'static str, + query: PageQuery, + filter: CatalogFilter, + sort: CatalogSort, + route: &str, + title: &str, ) -> Response { let (page_number, offset) = match page_params(&query) { Ok(params) => params, @@ -115,8 +363,15 @@ async fn feed( }; let source = state.source.clone(); - let result = - tokio::task::spawn_blocking(move || source.book_page(PAGE_SIZE as i64, offset)).await; + let result = tokio::task::spawn_blocking(move || { + source.book_page(CatalogBookQuery { + filter, + sort, + limit: PAGE_SIZE as i64, + offset, + }) + }) + .await; let (library_uuid, updated_at, page) = match result { Ok(Ok(page)) => page, Ok(Err(error)) => return calibre_error(error), @@ -135,6 +390,7 @@ async fn feed( page_number, last_page, route, + title, ) { Ok(xml) => ( [( @@ -148,6 +404,62 @@ async fn feed( } } +#[derive(Clone, Copy)] +enum CatalogFacetKind { + Authors, + Series, + Tags, + Genres, +} + +impl CatalogFacetKind { + fn title(self) -> &'static str { + match self { + Self::Authors => "Authors", + Self::Series => "Series", + Self::Tags => "Tags", + Self::Genres => "Genres", + } + } + + fn route(self) -> &'static str { + match self { + Self::Authors => "/opds/authors", + Self::Series => "/opds/series", + Self::Tags => "/opds/tags", + Self::Genres => "/opds/genres", + } + } +} + +async fn facet_handler(State(state): State, kind: CatalogFacetKind) -> Response { + let source = state.source.clone(); + let result = tokio::task::spawn_blocking(move || { + let library_uuid = source.active_library_id()?; + let facets = match kind { + CatalogFacetKind::Authors => source.authors()?, + CatalogFacetKind::Series => source.series()?, + CatalogFacetKind::Tags => source.tags()?, + CatalogFacetKind::Genres => source.genres()?, + }; + Ok::<_, CalibreError>((library_uuid, facets)) + }) + .await; + let (library_uuid, facets) = match result { + Ok(Ok(result)) => result, + Ok(Err(error)) => return calibre_error(error), + Err(_) => return public_error(StatusCode::INTERNAL_SERVER_ERROR, "Catalog unavailable"), + }; + match facet_navigation_feed(&library_uuid, kind, &facets) { + Ok(xml) => xml_response(NAVIGATION_CONTENT_TYPE, xml), + Err(_) => public_error(StatusCode::INTERNAL_SERVER_ERROR, "Catalog unavailable"), + } +} + +fn xml_response(content_type: &'static str, xml: Vec) -> Response { + ([(header::CONTENT_TYPE, content_type)], xml).into_response() +} + fn page_params(query: &PageQuery) -> Result<(u64, i64), Response> { let page_number = query.page.unwrap_or(1); if page_number == 0 { @@ -325,6 +637,7 @@ fn acquisition_feed( page_number: u64, last_page: u64, route: &str, + title: &str, ) -> Result, quick_xml::Error> { let mut links = vec![ FeedLink { @@ -419,7 +732,7 @@ fn acquisition_feed( let feed = Feed { id: library_identity(library_uuid), - title: "Citadel — All Books".to_string(), + title: format!("Citadel — {title}"), updated: feed_updated(updated_at), links, entries, @@ -427,6 +740,127 @@ fn acquisition_feed( crate::xml::write_feed(&feed) } +fn root_navigation_feed(library_uuid: &str) -> Result, quick_xml::Error> { + let entries = [ + ("all", "All Books", "/opds/all", ATOM_TYPE), + ("recent", "Recently Modified", "/opds/recent", ATOM_TYPE), + ("unread", "Unread", "/opds/unread", ATOM_TYPE), + ("authors", "Authors", "/opds/authors", NAVIGATION_TYPE), + ("series", "Series", "/opds/series", NAVIGATION_TYPE), + ("tags", "Tags", "/opds/tags", NAVIGATION_TYPE), + ("genres", "Genres", "/opds/genres", NAVIGATION_TYPE), + ]; + let feed = Feed { + id: navigation_identity(library_uuid, "/opds"), + title: "Citadel — Citadel".to_string(), + updated: feed_updated(None), + links: vec![ + FeedLink { + rel: "self", + href: "/opds".to_string(), + media_type: NAVIGATION_TYPE, + }, + FeedLink { + rel: "start", + href: "/opds".to_string(), + media_type: NAVIGATION_TYPE, + }, + FeedLink { + rel: "search", + href: "/opds/opensearch.xml".to_string(), + media_type: OPENSEARCH_TYPE, + }, + ], + entries: entries + .into_iter() + .map(|(id, title, href, media_type)| navigation_entry(library_uuid, id, title, href, media_type, None)) + .collect::, _>>()?, + }; + crate::xml::write_feed(&feed) +} + +fn facet_navigation_feed( + library_uuid: &str, + kind: CatalogFacetKind, + facets: &[CatalogFacet], +) -> Result, quick_xml::Error> { + let feed = Feed { + id: navigation_identity(library_uuid, kind.route()), + title: format!("Citadel — {}", kind.title()), + updated: feed_updated(None), + links: vec![ + FeedLink { + rel: "self", + href: kind.route().to_string(), + media_type: NAVIGATION_TYPE, + }, + FeedLink { + rel: "start", + href: "/opds".to_string(), + media_type: NAVIGATION_TYPE, + }, + FeedLink { + rel: "up", + href: "/opds".to_string(), + media_type: NAVIGATION_TYPE, + }, + ], + entries: facets + .iter() + .map(|facet| { + navigation_entry( + library_uuid, + &facet.id.to_string(), + &facet.title, + &format!("{}/{}", kind.route(), facet.id), + ATOM_TYPE, + facet.book_count, + ) + }) + .collect::, _>>()?, + }; + crate::xml::write_feed(&feed) +} + +fn navigation_entry( + library_uuid: &str, + id: &str, + title: &str, + href: &str, + media_type: &str, + count: Option, +) -> Result { + Ok(FeedEntry { + id: navigation_identity(library_uuid, id), + title: title.to_string(), + updated: feed_updated(None), + authors: Vec::new(), + published: feed_updated(None), + languages: Vec::new(), + identifiers: Vec::new(), + categories: Vec::new(), + acquisition_links: vec![( + "subsection".to_string(), + href.to_string(), + media_type.to_string(), + )], + image_link: None, + content: count.map(|count| format!("{count} books")), + }) +} + +fn opensearch_xml() -> Vec { + br#" + + Citadel + Search the active Citadel library + UTF-8 + UTF-8 + +"# + .to_vec() +} + fn page_count(total: u64) -> u64 { total.div_ceil(PAGE_SIZE) } @@ -434,6 +868,8 @@ fn page_count(total: u64) -> u64 { fn page_href(route: &str, page: u64) -> String { if page == 1 { route.to_string() + } else if route.contains('?') { + format!("{route}&page={page}") } else { format!("{route}?page={page}") } @@ -543,22 +979,18 @@ mod tests { impl CatalogSource for MemorySource { fn active_library_id(&self) -> Result { - self.books - .first() - .map(|_| "memory-library".to_string()) - .ok_or(CalibreError::LibraryNotInitialized) + Ok("550e8400-e29b-41d4-a716-446655440000".to_string()) } fn book_page( &self, - limit: i64, - offset: i64, + query: CatalogBookQuery, ) -> Result<(String, Option, BookPage), CalibreError> { let items = self .books .iter() - .skip(offset as usize) - .take(limit as usize) + .skip(query.offset as usize) + .take(query.limit as usize) .cloned() .collect(); Ok(( @@ -599,8 +1031,7 @@ mod tests { fn book_page( &self, - _limit: i64, - _offset: i64, + _query: CatalogBookQuery, ) -> Result<(String, Option, BookPage), CalibreError> { Err(CalibreError::LibraryNotInitialized) } @@ -625,8 +1056,7 @@ mod tests { fn book_page( &self, - _limit: i64, - _offset: i64, + _query: CatalogBookQuery, ) -> Result<(String, Option, BookPage), CalibreError> { Err((self.0)()) } @@ -651,16 +1081,60 @@ mod tests { fn book_page( &self, - limit: i64, - offset: i64, + query: CatalogBookQuery, ) -> Result<(String, Option, BookPage), CalibreError> { let mut library = self.library.lock().unwrap(); let uuid = library.library_uuid()?; let updated = library.catalog_updated_at()?; - let page = library.query_acquirable_books(limit, offset)?; + let page = match query.into_calibre() { + Some(query) => library.query_acquirable_books_with(query)?, + None => BookPage { + items: Vec::new(), + total: 0, + }, + }; Ok((uuid, updated, page)) } + fn authors(&self) -> Result, CalibreError> { + self.library.lock().unwrap().list_authors().map(|items| { + items + .into_iter() + .map(|item| CatalogFacet { + id: item.id.as_i32(), + title: item.name, + book_count: Some(item.book_count), + }) + .collect() + }) + } + + fn series(&self) -> Result, CalibreError> { + self.library.lock().unwrap().list_series().map(|items| { + items + .into_iter() + .map(|item| CatalogFacet { + id: item.id, + title: item.name, + book_count: Some(item.book_count), + }) + .collect() + }) + } + + fn tags(&self) -> Result, CalibreError> { + self.library.lock().unwrap().list_tags().map(|items| { + items + .into_iter() + .map(|item| CatalogFacet { + id: item.id, + title: item.name, + book_count: Some(item.book_count), + }) + .collect() + }) + } + fn book_file( &self, book_id: BookId, @@ -707,6 +1181,7 @@ mod tests { content: Vec, acquisition_hrefs: Vec, image_hrefs: Vec, + subsection_hrefs: Vec, next: Option, previous: Option, } @@ -753,6 +1228,9 @@ mod tests { parsed.acquisition_hrefs.push(href) } (Some(IMAGE_REL), Some(href)) if in_entry => parsed.image_hrefs.push(href), + (Some("subsection"), Some(href)) if in_entry => { + parsed.subsection_hrefs.push(href) + } (Some("next"), Some(href)) => parsed.next = Some(href), (Some("previous"), Some(href)) => parsed.previous = Some(href), _ => {} @@ -832,9 +1310,10 @@ mod tests { items: vec![legacy_book], total: 1, }; - let xml = - String::from_utf8(acquisition_feed("bad-library", None, &page, 1, 1, "/opds").unwrap()) - .unwrap(); + let xml = String::from_utf8( + acquisition_feed("bad-library", None, &page, 1, 1, "/opds", "All Books").unwrap(), + ) + .unwrap(); assert!(xml.contains("A <Book> & More")); assert!(xml.contains("A <Writer> & Co")); assert!(xml.contains("Words <with> & symbols")); @@ -858,18 +1337,123 @@ mod tests { items: Vec::new(), total: 101, }; - let first = - String::from_utf8(acquisition_feed("id", None, &page, 1, 3, "/opds/all").unwrap()) - .unwrap(); + let first = String::from_utf8( + acquisition_feed("id", None, &page, 1, 3, "/opds/all", "All Books").unwrap(), + ) + .unwrap(); assert!(first.contains("rel=\"next\" href=\"/opds/all?page=2\"")); assert!(!first.contains("rel=\"previous\"")); - let last = - String::from_utf8(acquisition_feed("id", None, &page, 3, 3, "/opds/all").unwrap()) - .unwrap(); + let last = String::from_utf8( + acquisition_feed("id", None, &page, 3, 3, "/opds/all", "All Books").unwrap(), + ) + .unwrap(); assert!(last.contains("rel=\"previous\" href=\"/opds/all?page=2\"")); assert!(!last.contains("rel=\"next\"")); } + #[test] + fn catalog_queries_translate_to_bounded_library_queries() { + let query = CatalogBookQuery { + filter: CatalogFilter::Unread, + sort: CatalogSort::Updated, + limit: 50, + offset: 100, + } + .into_calibre() + .unwrap(); + assert!(query.hide_read); + assert_eq!(query.sort, BookSortOrder::UpdatedDesc); + assert_eq!(query.limit, Some(50)); + assert_eq!(query.offset, 100); + + assert!(CatalogBookQuery { + filter: CatalogFilter::Genre(1), + sort: CatalogSort::Title, + limit: 50, + offset: 0, + } + .into_calibre() + .is_none()); + } + + #[tokio::test] + async fn root_exposes_v1_navigation_and_opensearch() { + let (base, server) = loopback(Arc::new(MemorySource { books: Vec::new() })).await; + let response = reqwest::get(format!("{base}/opds")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + NAVIGATION_CONTENT_TYPE + ); + let bytes = response.bytes().await.unwrap(); + let feed = parsed_feed(&bytes); + assert_eq!( + feed.titles, + [ + "All Books", + "Recently Modified", + "Unread", + "Authors", + "Series", + "Tags", + "Genres", + ] + ); + assert_eq!( + feed.subsection_hrefs, + [ + "/opds/all", + "/opds/recent", + "/opds/unread", + "/opds/authors", + "/opds/series", + "/opds/tags", + "/opds/genres", + ] + ); + let xml = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(xml.contains("rel=\"search\"")); + assert!(xml.contains("/opds/opensearch.xml")); + + let search = reqwest::get(format!("{base}/opds/opensearch.xml")) + .await + .unwrap(); + assert_eq!(search.status(), StatusCode::OK); + assert_eq!(search.headers()[header::CONTENT_TYPE], OPENSEARCH_TYPE); + let search = search.text().await.unwrap(); + assert!(search.contains("{searchTerms}")); + assert!(search.contains("/opds/search?q=")); + server.abort(); + } + + #[tokio::test] + async fn search_is_limited_and_preserves_query_in_pagination_links() { + let books = (0..51) + .map(|id| book(&format!("Result {id}"), &format!("uuid-{id}"), id + 1)) + .collect(); + let (base, server) = loopback(Arc::new(MemorySource { books })).await; + let response = reqwest::get(format!("{base}/opds/search?q=space%20%25%20_")) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let feed = parsed_feed(&response.bytes().await.unwrap()); + assert_eq!( + feed.next.as_deref(), + Some("/opds/search?q=space%20%25%20_&page=2") + ); + + for query in [String::new(), "x".repeat(MAX_SEARCH_LENGTH + 1)] { + let response = reqwest::get(format!( + "{base}/opds/search?q={}", + urlencoding::encode(&query) + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + server.abort(); + } + #[test] fn valid_uuids_are_normalized_and_fallbacks_are_deterministic() { assert_eq!( @@ -882,15 +1466,6 @@ mod tests { ); } - #[test] - fn page_count_is_zero_for_empty_totals_and_one_for_a_single_book() { - assert_eq!(page_count(0), 0); - assert_eq!(page_count(1), 1); - assert_eq!(page_count(50), 1); - assert_eq!(page_count(51), 2); - assert_eq!(page_count(101), 3); - } - #[tokio::test] async fn loopback_pagination_visits_every_entry_once() { let books = (1..=101) @@ -1041,12 +1616,16 @@ mod tests { })) .await; let client = reqwest::Client::new(); - let feed_head = client.head(format!("{base}/opds")).send().await.unwrap(); + let feed_head = client + .head(format!("{base}/opds/all")) + .send() + .await + .unwrap(); assert_eq!(feed_head.status(), StatusCode::OK); assert_eq!(feed_head.headers()[header::CONTENT_TYPE], ATOM_CONTENT_TYPE); assert!(feed_head.headers().get(header::LAST_MODIFIED).is_none()); assert!(feed_head.bytes().await.unwrap().is_empty()); - let response = client.get(format!("{base}/opds")).send().await.unwrap(); + let response = client.get(format!("{base}/opds/all")).send().await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let bytes = response.bytes().await.unwrap(); let xml = String::from_utf8(bytes.to_vec()).unwrap(); @@ -1133,7 +1712,7 @@ mod tests { })) .await; let client = reqwest::Client::new(); - let first = client.get(format!("{base}/opds")).send().await.unwrap(); + let first = client.get(format!("{base}/opds/all")).send().await.unwrap(); assert_eq!(first.status(), StatusCode::OK); let first = parsed_feed(&first.bytes().await.unwrap()); assert_eq!(first.ids.len(), 50); @@ -1160,7 +1739,7 @@ mod tests { #[tokio::test] async fn empty_and_unavailable_catalogs_return_valid_non_sensitive_responses() { let (base, server) = loopback(Arc::new(MemorySource { books: Vec::new() })).await; - let response = reqwest::get(format!("{base}/opds")).await.unwrap(); + let response = reqwest::get(format!("{base}/opds/all")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()[header::CONTENT_TYPE], ATOM_CONTENT_TYPE); let feed = parsed_feed(&response.bytes().await.unwrap()); @@ -1189,20 +1768,20 @@ mod tests { let (base, server) = loopback(Arc::new(MemorySource { books: Vec::new() })).await; for page in ["0", "18446744073709551615"] { let response = client - .get(format!("{base}/opds?page={page}")) + .get(format!("{base}/opds/all?page={page}")) .send() .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } let response = client - .get(format!("{base}/opds?page=2")) + .get(format!("{base}/opds/all?page=2")) .send() .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); let response = client - .get(format!("{base}/opds?page=nope")) + .get(format!("{base}/opds/all?page=nope")) .send() .await .unwrap(); diff --git a/crates/citadel-opds/src/identity.rs b/crates/citadel-opds/src/identity.rs index b1fa6516..2e789ee8 100644 --- a/crates/citadel-opds/src/identity.rs +++ b/crates/citadel-opds/src/identity.rs @@ -18,6 +18,14 @@ pub(crate) fn book_identity(library_uuid: &str, raw_uuid: Option<&str>, book_id: } } +pub(crate) fn navigation_identity(library_uuid: &str, key: &str) -> String { + format!( + "{}:navigation:{}", + library_identity(library_uuid), + hex(key.as_bytes()) + ) +} + fn hex(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut result = String::with_capacity(bytes.len() * 2); diff --git a/crates/citadel-opds/src/lib.rs b/crates/citadel-opds/src/lib.rs index 1e9cd67b..23eb8485 100644 --- a/crates/citadel-opds/src/lib.rs +++ b/crates/citadel-opds/src/lib.rs @@ -10,7 +10,9 @@ pub mod service; mod xml; pub use auth::OpdsBasicAuth; -pub use catalog::{router, CatalogSource}; +pub use catalog::{ + router, CatalogBookQuery, CatalogFacet, CatalogFilter, CatalogSort, CatalogSource, +}; pub use credentials::{GeneratedOpdsCredentials, OpdsCredentialStatus}; pub use network::{OpdsInterfaceKind, OpdsInterfaceState, OpdsNetworkInterface}; pub use service::{ diff --git a/crates/citadel-opds/src/service.rs b/crates/citadel-opds/src/service.rs index d55052d5..83069cf3 100644 --- a/crates/citadel-opds/src/service.rs +++ b/crates/citadel-opds/src/service.rs @@ -935,8 +935,7 @@ mod tests { fn book_page( &self, - _limit: i64, - _offset: i64, + _query: crate::catalog::CatalogBookQuery, ) -> Result< (String, Option, libcalibre::BookPage), libcalibre::CalibreError, diff --git a/crates/citadel-server/src/lib.rs b/crates/citadel-server/src/lib.rs index 2145e6bc..eab92e82 100644 --- a/crates/citadel-server/src/lib.rs +++ b/crates/citadel-server/src/lib.rs @@ -8,8 +8,8 @@ use std::{ use chrono::NaiveDateTime; use citadel_opds::{ - CatalogSource, OpdsErrorCode, OpdsLifecycleState, OpdsService, OpdsServiceStatus, - OpdsStartConfig, + CatalogBookQuery, CatalogFacet, CatalogSource, OpdsErrorCode, OpdsLifecycleState, OpdsService, + OpdsServiceStatus, OpdsStartConfig, }; use libcalibre::{BookId, BookPage, CalibreError, Library, ResolvedBookAsset}; use serde::Deserialize; @@ -61,16 +61,71 @@ impl CatalogSource for CalibreCatalogSource { fn book_page( &self, - limit: i64, - offset: i64, + query: CatalogBookQuery, ) -> Result<(String, Option, BookPage), CalibreError> { let mut library = self.library.lock().expect("server library mutex poisoned"); let library_id = library.library_uuid()?; let updated_at = library.catalog_updated_at()?; - let page = library.query_acquirable_books(limit, offset)?; + let page = match query.into_calibre() { + Some(query) => library.query_acquirable_books_with(query)?, + None => BookPage { + items: Vec::new(), + total: 0, + }, + }; Ok((library_id, updated_at, page)) } + fn authors(&self) -> Result, CalibreError> { + self.library + .lock() + .expect("server library mutex poisoned") + .list_authors() + .map(|authors| { + authors + .into_iter() + .map(|author| CatalogFacet { + id: author.id.as_i32(), + title: author.name, + book_count: Some(author.book_count), + }) + .collect() + }) + } + + fn series(&self) -> Result, CalibreError> { + self.library + .lock() + .expect("server library mutex poisoned") + .list_series() + .map(|series| { + series + .into_iter() + .map(|series| CatalogFacet { + id: series.id, + title: series.name, + book_count: Some(series.book_count), + }) + .collect() + }) + } + + fn tags(&self) -> Result, CalibreError> { + self.library + .lock() + .expect("server library mutex poisoned") + .list_tags() + .map(|tags| { + tags.into_iter() + .map(|tag| CatalogFacet { + id: tag.id, + title: tag.name, + book_count: Some(tag.book_count), + }) + .collect() + }) + } + fn book_file(&self, book_id: BookId, format: &str) -> Result { self.library .lock() diff --git a/crates/citadel-server/tests/headless_process.rs b/crates/citadel-server/tests/headless_process.rs index 03354134..1c8e201a 100644 --- a/crates/citadel-server/tests/headless_process.rs +++ b/crates/citadel-server/tests/headless_process.rs @@ -120,15 +120,23 @@ passwordEnvironment = "CITADEL_TEST_OPDS_PASSWORD" .build() .unwrap(); assert_eq!(client.get(&catalog_url).send().unwrap().status(), 401); - let feed = client + let navigation = client .get(&catalog_url) .basic_auth("reader", Some("secret")) .send() .unwrap(); + assert!(navigation.status().is_success()); + assert!(navigation.text().unwrap().contains("All Books")); + + let origin = catalog_url.strip_suffix("/opds").unwrap(); + let feed = client + .get(format!("{origin}/opds/all")) + .basic_auth("reader", Some("secret")) + .send() + .unwrap(); assert!(feed.status().is_success()); assert!(feed.text().unwrap().contains("Headless Citadel")); - let origin = catalog_url.strip_suffix("/opds").unwrap(); let acquisition = client .get(format!( "{origin}/opds/books/{}/files/EPUB/book.epub", diff --git a/crates/libcalibre/src/lib.rs b/crates/libcalibre/src/lib.rs index 3015110d..5aabf3ef 100644 --- a/crates/libcalibre/src/lib.rs +++ b/crates/libcalibre/src/lib.rs @@ -19,9 +19,9 @@ pub mod util; pub use custom_columns::{CustomColumn, CustomColumnKind, CustomColumnSpec, CustomValue}; pub use error::CalibreError; pub use library::{ - Author as LibraryAuthor, AuthorAdd, AuthorUpdate, Book as LibraryBook, BookAdd, BookFileInfo, - BookIdentifier, BookPage, BookQuery, BookSortOrder, BookUpdate, Library, ResolvedBookAsset, - SeriesSummary, TagSummary, + Author as LibraryAuthor, AuthorAdd, AuthorSummary, AuthorUpdate, Book as LibraryBook, BookAdd, + BookFileInfo, BookIdentifier, BookPage, BookQuery, BookSortOrder, BookUpdate, Library, + ResolvedBookAsset, SeriesSummary, TagSummary, }; pub use stats::{library_stats, LibraryStats}; pub use types::{AuthorId, BookFileId, BookId}; diff --git a/crates/libcalibre/src/library.rs b/crates/libcalibre/src/library.rs index 87a680d7..0c8a77f1 100644 --- a/crates/libcalibre/src/library.rs +++ b/crates/libcalibre/src/library.rs @@ -151,6 +151,10 @@ pub enum BookSortOrder { TitleDesc, AuthorAsc, AuthorDesc, + /// Most recently modified first, with book id as a stable tiebreaker. + UpdatedDesc, + /// Calibre series position first, then title and id for deterministic ties. + SeriesIndexAsc, } /// A paged, sorted, filtered book query. All filters compose (AND). @@ -166,9 +170,13 @@ pub struct BookQuery { pub author_id: Option, /// Only books linked to this series. pub series_id: Option, + /// Only books linked to this tag. + pub tag_id: Option, /// Exclude books marked read (filtered in SQL, so paging and totals stay /// correct). pub hide_read: bool, + /// Only books with at least one Calibre `data` row. + pub require_file: bool, pub sort: BookSortOrder, /// Maximum number of books to return. `None` returns all matches. pub limit: Option, @@ -196,12 +204,21 @@ pub struct SeriesSummary { pub book_count: i64, } +/// One author in the library with its linked-book count. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthorSummary { + pub id: AuthorId, + pub name: String, + pub book_count: i64, +} + /// One tag in the library. Returned by [`Library::list_tags`]; the full /// vocabulary feeds tag autocomplete in clients. #[derive(Clone, Debug, PartialEq, Eq)] pub struct TagSummary { pub id: i32, pub name: String, + pub book_count: i64, } impl Library { @@ -815,7 +832,14 @@ impl Library { } pub fn get_book_read_state(&mut self, book_id: BookId) -> Result { - let column = self.get_or_create_read_state_column()?; + let Some(column) = custom_columns::find_by_label_and_kind( + &mut self.conn, + "read", + &CustomColumnKind::Bool, + )? + else { + return Ok(false); + }; let value = custom_columns::get_value(&mut self.conn, &column, book_id)?; Ok(matches!(value, Some(CustomValue::Bool(true)))) } @@ -842,7 +866,14 @@ impl Library { return Ok(HashMap::new()); } - let column = self.get_or_create_read_state_column()?; + let Some(column) = custom_columns::find_by_label_and_kind( + &mut self.conn, + "read", + &CustomColumnKind::Bool, + )? + else { + return Ok(HashMap::new()); + }; let values = custom_columns::batch_get_values(&mut self.conn, &column, book_ids)?; Ok(values @@ -888,7 +919,9 @@ impl Library { .filter(|text| !text.is_empty()), author_id: query.author_id, series_id: query.series_id, + tag_id: query.tag_id, hide_read_column, + require_file: query.require_file, }; let total = book_queries::query_count(&mut self.conn, &filters)?; @@ -912,9 +945,43 @@ impl Library { limit: i64, offset: i64, ) -> Result { + self.query_acquirable_books_with(BookQuery { + limit: Some(limit), + offset, + ..BookQuery::default() + }) + } + + /// Run a filtered, sorted catalog query while excluding books whose file + /// records cannot be resolved safely beneath the library root. Candidate + /// rows are scanned without hydrating books; only the selected page is + /// hydrated. + pub fn query_acquirable_books_with( + &mut self, + query: BookQuery, + ) -> Result { + let hide_read_column = if query.hide_read { + custom_columns::find_by_label_and_kind(&mut self.conn, "read", &CustomColumnKind::Bool)? + .map(|column| column.id) + } else { + None + }; + let filters = book_queries::BookPageFilters { + text: query + .text + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()), + author_id: query.author_id, + series_id: query.series_id, + tag_id: query.tag_id, + hide_read_column, + require_file: true, + }; let mut resolvable_ids = Vec::new(); let mut seen = std::collections::HashSet::new(); - for candidate in book_queries::acquisition_candidates(&mut self.conn)? { + for candidate in book_queries::acquisition_candidates(&mut self.conn, &filters, query.sort)? + { let book_id = BookId(candidate.book_id); if seen.contains(&book_id) { continue; @@ -930,8 +997,11 @@ impl Library { } } let total = u64::try_from(resolvable_ids.len()).expect("usize fits in u64"); - let start = usize::try_from(offset.max(0)).unwrap_or(usize::MAX); - let page_len = usize::try_from(limit.max(0)).unwrap_or(usize::MAX); + let start = usize::try_from(query.offset.max(0)).unwrap_or(usize::MAX); + let page_len = query + .limit + .map(|limit| usize::try_from(limit.max(0)).unwrap_or(usize::MAX)) + .unwrap_or(usize::MAX); let book_ids = resolvable_ids .into_iter() .skip(start) @@ -962,6 +1032,11 @@ impl Library { crate::queries::series::list_with_book_counts(&mut self.conn) } + /// List authors with linked-book counts, sorted case-insensitively. + pub fn list_authors(&mut self) -> Result, CalibreError> { + author_queries::list_with_book_counts(&mut self.conn) + } + /// List every tag in the library (the whole vocabulary, including tags /// no longer linked to any book), sorted case-insensitively by name. pub fn list_tags(&mut self) -> Result, CalibreError> { diff --git a/crates/libcalibre/src/queries/authors.rs b/crates/libcalibre/src/queries/authors.rs index 8b81b264..e8e3f99b 100644 --- a/crates/libcalibre/src/queries/authors.rs +++ b/crates/libcalibre/src/queries/authors.rs @@ -10,12 +10,47 @@ use std::collections::HashMap; use diesel::prelude::*; -use diesel::{QueryDsl, RunQueryDsl, SqliteConnection}; +use diesel::sql_query; +use diesel::sql_types::{BigInt, Integer, Text}; +use diesel::{QueryDsl, QueryableByName, RunQueryDsl, SqliteConnection}; use crate::entities::author::{NewAuthor, UpdateAuthorData}; use crate::sorting; use crate::types::BookId; -use crate::{types::AuthorId, Author, CalibreError}; +use crate::{library::AuthorSummary, types::AuthorId, Author, CalibreError}; + +pub(crate) fn list_with_book_counts( + conn: &mut SqliteConnection, +) -> Result, CalibreError> { + #[derive(QueryableByName)] + struct AuthorCountRow { + #[diesel(sql_type = Integer)] + id: i32, + #[diesel(sql_type = Text)] + name: String, + #[diesel(sql_type = BigInt)] + book_count: i64, + } + + let rows: Vec = sql_query( + "SELECT a.id AS id, a.name AS name, COUNT(bal.book) AS book_count + FROM authors a + LEFT JOIN books_authors_link bal ON bal.author = a.id + GROUP BY a.id, a.name + ORDER BY a.name COLLATE NOCASE, a.name, a.id", + ) + .load(conn) + .map_err(CalibreError::from)?; + + Ok(rows + .into_iter() + .map(|row| AuthorSummary { + id: AuthorId(row.id), + name: row.name, + book_count: row.book_count, + }) + .collect()) +} pub(crate) fn get( conn: &mut SqliteConnection, diff --git a/crates/libcalibre/src/queries/books.rs b/crates/libcalibre/src/queries/books.rs index 4105bc30..21f61235 100644 --- a/crates/libcalibre/src/queries/books.rs +++ b/crates/libcalibre/src/queries/books.rs @@ -103,9 +103,11 @@ pub(crate) struct BookPageFilters<'a> { pub text: Option<&'a str>, pub author_id: Option, pub series_id: Option, + pub tag_id: Option, /// Id of the `read` bool custom column. When set, books marked read are /// excluded. pub hide_read_column: Option, + pub require_file: bool, } #[derive(QueryableByName)] @@ -132,19 +134,6 @@ pub(crate) struct AcquisitionCandidate { pub name: String, } -pub(crate) fn acquisition_candidates( - conn: &mut SqliteConnection, -) -> Result, CalibreError> { - sql_query( - "SELECT books.id AS book_id, books.path AS book_path, \ - data.format AS format, data.name AS name \ - FROM books JOIN data ON data.book = books.id \ - ORDER BY books.sort ASC, books.id ASC, data.id ASC", - ) - .load(conn) - .map_err(CalibreError::from) -} - fn like_pattern(text: &str) -> String { let escaped = text .replace('\\', "\\\\") @@ -187,6 +176,13 @@ fn filter_where_sql(filters: &BookPageFilters) -> String { )); } + if let Some(tag_id) = filters.tag_id { + clauses.push(format!( + "EXISTS (SELECT 1 FROM books_tags_link btl2 \ + WHERE btl2.book = books.id AND btl2.tag = {tag_id})" + )); + } + if let Some(n) = filters.hide_read_column { clauses.push(format!( "NOT EXISTS (SELECT 1 FROM custom_column_{n} cc \ @@ -194,6 +190,10 @@ fn filter_where_sql(filters: &BookPageFilters) -> String { )); } + if filters.require_file { + clauses.push("EXISTS (SELECT 1 FROM data d WHERE d.book = books.id)".to_string()); + } + clauses.join(" AND ") } @@ -210,6 +210,39 @@ fn order_by_sql(sort: BookSortOrder) -> String { BookSortOrder::TitleDesc => "books.sort DESC, books.id DESC".to_string(), BookSortOrder::AuthorAsc => format!("{AUTHOR_SORT} ASC, books.id ASC"), BookSortOrder::AuthorDesc => format!("{AUTHOR_SORT} DESC, books.id DESC"), + BookSortOrder::UpdatedDesc => "books.last_modified DESC, books.id DESC".to_string(), + BookSortOrder::SeriesIndexAsc => { + "books.series_index IS NULL ASC, books.series_index ASC, books.sort ASC, books.id ASC" + .to_string() + } + } +} + +pub(crate) fn acquisition_candidates( + conn: &mut SqliteConnection, + filters: &BookPageFilters, + sort: BookSortOrder, +) -> Result, CalibreError> { + let where_sql = filter_where_sql(filters); + let order_sql = order_by_sql(sort); + let sql = format!( + "SELECT books.id AS book_id, books.path AS book_path, \ + data.format AS format, data.name AS name \ + FROM books JOIN data ON data.book = books.id \ + WHERE {where_sql} ORDER BY {order_sql}, data.id ASC" + ); + + match filters.text { + Some(text) => { + let pattern = like_pattern(text); + sql_query(sql) + .bind::(&pattern) + .bind::(&pattern) + .bind::(&pattern) + .load(conn) + .map_err(CalibreError::from) + } + None => sql_query(sql).load(conn).map_err(CalibreError::from), } } diff --git a/crates/libcalibre/src/queries/tags.rs b/crates/libcalibre/src/queries/tags.rs index a985ec6f..a58330f6 100644 --- a/crates/libcalibre/src/queries/tags.rs +++ b/crates/libcalibre/src/queries/tags.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use diesel::prelude::*; use diesel::sql_query; -use diesel::sql_types::{Integer, Text}; +use diesel::sql_types::{BigInt, Integer, Text}; use diesel::{QueryDsl, RunQueryDsl, SqliteConnection}; use crate::entities::tag::NewTag; @@ -13,20 +13,34 @@ use crate::{CalibreError, Tag}; /// Every tag in the library, sorted case-insensitively by name (with the /// raw name as a stable tiebreak, matching [`find_for_book`]). pub(crate) fn list_all(conn: &mut SqliteConnection) -> Result, CalibreError> { - use crate::schema::tags::dsl::*; + #[derive(QueryableByName)] + struct TagCountRow { + #[diesel(sql_type = Integer)] + id: i32, + #[diesel(sql_type = Text)] + name: String, + #[diesel(sql_type = BigInt)] + book_count: i64, + } - tags.select(Tag::as_select()) - .order_by(diesel::dsl::sql::("LOWER(name), name")) - .load(conn) - .map(|rows: Vec| { - rows.into_iter() - .map(|tag| TagSummary { - id: tag.id, - name: tag.name, - }) - .collect() + let rows: Vec = sql_query( + "SELECT t.id AS id, t.name AS name, COUNT(btl.book) AS book_count + FROM tags t + LEFT JOIN books_tags_link btl ON btl.tag = t.id + GROUP BY t.id, t.name + ORDER BY t.name COLLATE NOCASE, t.name, t.id", + ) + .load(conn) + .map_err(CalibreError::from)?; + + Ok(rows + .into_iter() + .map(|row| TagSummary { + id: row.id, + name: row.name, + book_count: row.book_count, }) - .map_err(CalibreError::from) + .collect()) } pub(crate) fn find_by_name_case_insensitive( diff --git a/crates/libcalibre/tests/query_test.rs b/crates/libcalibre/tests/query_test.rs index 0368b671..1031471b 100644 --- a/crates/libcalibre/tests/query_test.rs +++ b/crates/libcalibre/tests/query_test.rs @@ -51,6 +51,15 @@ fn series_id_by_name(lib: &mut Library, name: &str) -> i32 { row.id } +fn tag_id_by_name(lib: &mut Library, name: &str) -> i32 { + lib.list_tags() + .unwrap() + .into_iter() + .find(|tag| tag.name == name) + .expect("tag not found") + .id +} + // ============================================================================= // Paging // ============================================================================= @@ -194,6 +203,63 @@ fn test_sort_author_asc() { ); } +#[test] +fn test_sort_updated_desc_uses_id_as_stable_tiebreaker() { + let (_temp, mut lib) = setup_with_library(); + let older = lib.add_book(book("Older", &[])).unwrap(); + let newer_first = lib.add_book(book("Newer First", &[])).unwrap(); + let newer_second = lib.add_book(book("Newer Second", &[])).unwrap(); + + let mut conn = libcalibre::persistence::establish_connection(lib.database_path()).unwrap(); + sql_query("UPDATE books SET last_modified = '2024-01-01 00:00:00' WHERE id = ?") + .bind::(older.id.as_i32()) + .execute(&mut conn) + .unwrap(); + sql_query("UPDATE books SET last_modified = '2024-02-01 00:00:00' WHERE id IN (?, ?)") + .bind::(newer_first.id.as_i32()) + .bind::(newer_second.id.as_i32()) + .execute(&mut conn) + .unwrap(); + + let page = query( + &mut lib, + BookQuery { + sort: BookSortOrder::UpdatedDesc, + ..BookQuery::default() + }, + ); + assert_eq!(titles(&page), ["Newer Second", "Newer First", "Older"]); +} + +#[test] +fn test_sort_series_index_handles_fractions_and_stable_ties() { + let (_temp, mut lib) = setup_with_library(); + for (title, index) in [ + ("Second B", 2.0), + ("First", 1.0), + ("Second A", 2.0), + ("Bridge", 1.5), + ] { + lib.add_book(BookAdd { + series: Some("Saga".to_string()), + series_index: Some(index), + ..book(title, &[]) + }) + .unwrap(); + } + let saga = series_id_by_name(&mut lib, "Saga"); + + let page = query( + &mut lib, + BookQuery { + series_id: Some(saga), + sort: BookSortOrder::SeriesIndexAsc, + ..BookQuery::default() + }, + ); + assert_eq!(titles(&page), ["First", "Bridge", "Second A", "Second B"]); +} + #[test] fn test_sort_author_desc() { let (_temp, mut lib) = setup_with_library(); @@ -460,6 +526,11 @@ fn test_hide_read_with_no_read_column_matches_all() { ); assert_eq!(page.items.len(), 1); assert_eq!(page.total, 1); + assert!(lib + .custom_columns() + .unwrap() + .iter() + .all(|column| column.label != "read")); } #[test] @@ -536,6 +607,92 @@ fn test_page_items_are_hydrated() { // Tag listing // ============================================================================= +#[test] +fn test_list_authors_returns_counts_sorted_case_insensitively() { + let (_temp, mut lib) = setup_with_library(); + lib.add_book(book("First", &["zebra", "Alpha"])).unwrap(); + lib.add_book(book("Second", &["zebra"])).unwrap(); + + let authors = lib.list_authors().unwrap(); + let summary: Vec<_> = authors + .iter() + .map(|author| (author.name.as_str(), author.book_count)) + .collect(); + assert_eq!(summary, [("Alpha", 1), ("zebra", 2)]); +} + +#[test] +fn test_tag_filter_composes_with_paging_and_totals() { + let (_temp, mut lib) = setup_with_library(); + + for title in ["Alpha", "Beta", "Gamma"] { + lib.add_book(BookAdd { + tags: Some(vec!["Selected".to_string()]), + ..book(title, &[]) + }) + .unwrap(); + } + lib.add_book(BookAdd { + tags: Some(vec!["Other".to_string()]), + ..book("Excluded", &[]) + }) + .unwrap(); + + let selected_tag = tag_id_by_name(&mut lib, "Selected"); + let page = query( + &mut lib, + BookQuery { + tag_id: Some(selected_tag), + limit: Some(2), + offset: 1, + ..BookQuery::default() + }, + ); + assert_eq!(titles(&page), ["Beta", "Gamma"]); + assert_eq!(page.total, 3); +} + +#[test] +fn test_acquirable_query_composes_filters_and_excludes_fileless_books() { + let (temp, mut lib) = setup_with_library(); + let source = temp.path().join("source.epub"); + std::fs::write(&source, b"epub").unwrap(); + + for title in ["Alpha Match", "Beta Match"] { + lib.add_book(BookAdd { + tags: Some(vec!["Selected".to_string()]), + file_paths: vec![source.clone()], + ..book(title, &["Writer"]) + }) + .unwrap(); + } + lib.add_book(BookAdd { + tags: Some(vec!["Selected".to_string()]), + ..book("Fileless Match", &["Writer"]) + }) + .unwrap(); + lib.add_book(BookAdd { + tags: Some(vec!["Other".to_string()]), + file_paths: vec![source], + ..book("Other Match", &["Writer"]) + }) + .unwrap(); + + let selected_tag = tag_id_by_name(&mut lib, "Selected"); + let page = lib + .query_acquirable_books_with(BookQuery { + text: Some("match".to_string()), + tag_id: Some(selected_tag), + limit: Some(1), + offset: 1, + ..BookQuery::default() + }) + .unwrap(); + assert_eq!(titles(&page), ["Beta Match"]); + assert_eq!(page.total, 2); + assert_eq!(page.items[0].files.len(), 1); +} + #[test] fn test_list_tags_returns_deduped_vocabulary_sorted_by_name() { let (_temp, mut lib) = setup_with_library(); diff --git a/crates/libcalibre/tests/snapshots/add_book_test__add_single_book_full_database.snap b/crates/libcalibre/tests/snapshots/add_book_test__add_single_book_full_database.snap index b01adccf..a73964c9 100644 --- a/crates/libcalibre/tests/snapshots/add_book_test__add_single_book_full_database.snap +++ b/crates/libcalibre/tests/snapshots/add_book_test__add_single_book_full_database.snap @@ -357,20 +357,6 @@ schema: type_name: BLOB nullable: false primary_key: false - - name: custom_column_1 - columns: - - name: id - type_name: INTEGER - nullable: true - primary_key: true - - name: book - type_name: INTEGER - nullable: true - primary_key: false - - name: value - type_name: BOOL - nullable: false - primary_key: false - name: custom_columns columns: - name: id @@ -677,17 +663,7 @@ data: books_tags_link: [] comments: [] conversion_options: [] - custom_column_1: [] - custom_columns: - - datatype: bool - display: "{}" - editable: 1 - id: 1 - is_multiple: 0 - label: read - mark_for_delete: 0 - name: Read - normalized: 0 + custom_columns: [] data: [] feeds: [] identifiers: [] diff --git a/src-tauri/src/libs/calibre/query.rs b/src-tauri/src/libs/calibre/query.rs index 366846a4..cfb2f190 100644 --- a/src-tauri/src/libs/calibre/query.rs +++ b/src-tauri/src/libs/calibre/query.rs @@ -116,7 +116,9 @@ pub fn clb_query_books( text: query.text, author_id, series_id: query.series_id, + tag_id: None, hide_read: query.hide_read, + require_file: false, sort: query.sort.into(), limit: query.limit.map(i64::from), offset: i64::from(query.offset), diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 5f20151f..c5e76ca5 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -6,7 +6,7 @@ use std::sync::{ use chrono::NaiveDateTime; use libcalibre::{BookId, BookPage, CalibreError, Library, ResolvedBookAsset}; -use citadel_opds::CatalogSource; +use citadel_opds::{CatalogBookQuery, CatalogFacet, CatalogSource}; #[derive(Clone)] pub struct CitadelState { @@ -140,8 +140,7 @@ impl CitadelState { pub fn opds_book_page( &self, - limit: i64, - offset: i64, + query: CatalogBookQuery, ) -> Result<(String, Option, BookPage), CalibreError> { let mut library = self.inner.library.lock().expect("Library mutex poisoned"); if self.is_library_transitioning() { @@ -152,9 +151,30 @@ impl CitadelState { .ok_or(CalibreError::LibraryNotInitialized)?; let library_uuid = library.library_uuid()?; let updated_at = library.catalog_updated_at()?; - let page = library.query_acquirable_books(limit, offset)?; + let page = match query.into_calibre() { + Some(query) => library.query_acquirable_books_with(query)?, + None => BookPage { + items: Vec::new(), + total: 0, + }, + }; Ok((library_uuid, updated_at, page)) } + + fn with_opds_library( + &self, + operation: impl FnOnce(&mut Library) -> Result, + ) -> Result { + let mut library = self.inner.library.lock().expect("Library mutex poisoned"); + if self.is_library_transitioning() { + return Err(CalibreError::LibraryNotInitialized); + } + operation( + library + .as_mut() + .ok_or(CalibreError::LibraryNotInitialized)?, + ) + } } impl CatalogSource for CitadelState { @@ -164,10 +184,53 @@ impl CatalogSource for CitadelState { fn book_page( &self, - limit: i64, - offset: i64, + query: CatalogBookQuery, ) -> Result<(String, Option, BookPage), CalibreError> { - self.opds_book_page(limit, offset) + self.opds_book_page(query) + } + + fn authors(&self) -> Result, CalibreError> { + self.with_opds_library(|library| { + library.list_authors().map(|authors| { + authors + .into_iter() + .map(|author| CatalogFacet { + id: author.id.as_i32(), + title: author.name, + book_count: Some(author.book_count), + }) + .collect() + }) + }) + } + + fn series(&self) -> Result, CalibreError> { + self.with_opds_library(|library| { + library.list_series().map(|series| { + series + .into_iter() + .map(|series| CatalogFacet { + id: series.id, + title: series.name, + book_count: Some(series.book_count), + }) + .collect() + }) + }) + } + + fn tags(&self) -> Result, CalibreError> { + self.with_opds_library(|library| { + library.list_tags().map(|tags| { + tags.into_iter() + .map(|tag| CatalogFacet { + id: tag.id, + title: tag.name, + book_count: Some(tag.book_count), + }) + .collect() + }) + }) } fn book_file(&self, book_id: BookId, format: &str) -> Result {