From 0f45c34fdb4c65c12b3b2082251f904bc385514c Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Sat, 5 Sep 2026 04:33:05 +0000 Subject: [PATCH] Add the BookOrbit OPDS client module Searches BookOrbit's OPDS catalog for an issue's Standard edition and extracts the book/file ids the web reader route needs. Hand-rolled Atom parsing, no new dependencies; unit-tested against the real feed shape. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW --- src/bookorbit.rs | 508 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 509 insertions(+) create mode 100644 src/bookorbit.rs diff --git a/src/bookorbit.rs b/src/bookorbit.rs new file mode 100644 index 0000000..9b7623a --- /dev/null +++ b/src/bookorbit.rs @@ -0,0 +1,508 @@ +//! BookOrbit OPDS catalog lookup support. +//! +//! BookOrbit is the companion library and browser-based EPUB reader used by +//! this service. Its OPDS catalog is preferable to its JSON API here because +//! OPDS uses static HTTP Basic credentials and therefore needs no JWT/refresh +//! token lifecycle. Searches use `/api/v1/opds/catalog?q=`, acquisition +//! links expose `/api/v1/opds//download?fileId=`, and browser +//! links use `/read//`. See +//! `docs/plans/2026-09-05-bookorbit-read-link.md` for the integration design. + +use jiff::civil::Date; +use reqwest::header::ACCEPT; + +const ACQUISITION_REL: &str = "http://opds-spec.org/acquisition"; +const DOWNLOAD_PREFIX: &str = "/api/v1/opds/"; + +/// The BookOrbit book and file identifiers required by its reader route. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BookorbitIds { + /// BookOrbit's identifier for the issue's book record. + pub book_id: i64, + /// BookOrbit's identifier for the EPUB file attached to the book. + pub file_id: i64, +} + +/// A failure while querying or parsing BookOrbit's OPDS catalog. +#[derive(Debug, thiserror::Error)] +pub enum BookorbitError { + /// The OPDS endpoint could not be reached. + #[error("BookOrbit unreachable: {0}")] + Unreachable(#[source] reqwest::Error), + /// BookOrbit rejected the configured OPDS Basic credentials. + #[error("BookOrbit rejected the OPDS credentials")] + Unauthorized, + /// BookOrbit returned an unexpected non-success status. + #[error("BookOrbit returned HTTP {0}")] + Status(reqwest::StatusCode), + /// BookOrbit returned a body that could not be read as the expected feed. + #[error("BookOrbit returned an unreadable OPDS feed: {0}")] + Malformed(String), +} + +/// Search BookOrbit's OPDS catalog for the Standard edition of the issue dated `date`. +/// +/// `api_url` must have no trailing slash. `Ok(None)` means BookOrbit has not +/// indexed the issue yet. +pub async fn find_issue( + client: &reqwest::Client, + api_url: &str, + opds_user: &str, + opds_pass: &str, + issue_title: &str, + date: Date, +) -> Result, BookorbitError> { + let response = client + .get(format!("{api_url}/api/v1/opds/catalog")) + .query(&[("q", date.to_string())]) + .basic_auth(opds_user, Some(opds_pass)) + .header(ACCEPT, "application/atom+xml") + .send() + .await + .map_err(BookorbitError::Unreachable)?; + + let status = response.status(); + if matches!( + status, + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN + ) { + return Err(BookorbitError::Unauthorized); + } + if !status.is_success() { + return Err(BookorbitError::Status(status)); + } + + let feed_xml = response.text().await.map_err(|error| { + if error.is_timeout() || error.is_connect() { + BookorbitError::Unreachable(error) + } else { + BookorbitError::Malformed(error.to_string()) + } + })?; + let ids = select_issue_entry(&feed_xml, issue_title, date)?; + if let Some(ids) = ids { + tracing::info!( + book_id = ids.book_id, + file_id = ids.file_id, + "resolved BookOrbit issue" + ); + } + Ok(ids) +} + +/// Select the Standard issue from an Atom feed without performing network I/O. +pub fn select_issue_entry( + feed_xml: &str, + issue_title: &str, + date: Date, +) -> Result, BookorbitError> { + if find_open_tag(feed_xml, "feed", 0).is_none() { + return Err(BookorbitError::Malformed( + "response does not contain an Atom element".to_string(), + )); + } + + let fallback_title = format!("The Daily EPUB - {date}"); + let mut fallback_entry = None; + + for entry in entry_bodies(feed_xml) { + let Some(title) = element_text(entry, "title") else { + continue; + }; + let title = xml_unescape(title); + let title = title.trim(); + if title.ends_with("(X4)") { + continue; + } + if title == issue_title { + return acquisition_ids(entry); + } + if title == fallback_title && fallback_entry.is_none() { + fallback_entry = Some(entry); + } + } + + fallback_entry.map_or(Ok(None), acquisition_ids) +} + +/// Build the public BookOrbit reader URL for `ids`. +/// +/// `public_url` must have no trailing slash. +pub fn reader_url(public_url: &str, ids: BookorbitIds) -> String { + format!("{public_url}/read/{}/{}", ids.book_id, ids.file_id) +} + +fn entry_bodies(feed_xml: &str) -> Vec<&str> { + let mut entries = Vec::new(); + let mut cursor = 0; + + while let Some(start) = find_open_tag(feed_xml, "entry", cursor) { + let Some(open_end_offset) = feed_xml[start..].find('>') else { + break; + }; + let body_start = start + open_end_offset + 1; + let Some(close_offset) = feed_xml[body_start..].find("") else { + break; + }; + let body_end = body_start + close_offset; + entries.push(&feed_xml[body_start..body_end]); + cursor = body_end + "".len(); + } + + entries +} + +fn element_text<'a>(xml: &'a str, name: &str) -> Option<&'a str> { + let start = find_open_tag(xml, name, 0)?; + let open_end = start + xml[start..].find('>')?; + let text_start = open_end + 1; + let close = format!(""); + let text_end = text_start + xml[text_start..].find(&close)?; + Some(&xml[text_start..text_end]) +} + +fn find_open_tag(xml: &str, name: &str, mut cursor: usize) -> Option { + let needle = format!("<{name}"); + while let Some(offset) = xml[cursor..].find(&needle) { + let start = cursor + offset; + let after_name = start + needle.len(); + if xml + .as_bytes() + .get(after_name) + .is_some_and(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/')) + { + return Some(start); + } + cursor = after_name; + } + None +} + +fn acquisition_ids(entry: &str) -> Result, BookorbitError> { + let mut cursor = 0; + let mut saw_acquisition = false; + + while let Some(start) = find_open_tag(entry, "link", cursor) { + let Some(end_offset) = entry[start..].find('>') else { + break; + }; + let end = start + end_offset; + let attributes = &entry[start + "(attributes: &'a str, wanted: &str) -> Option<&'a str> { + let bytes = attributes.as_bytes(); + let mut cursor = 0; + + while cursor < bytes.len() { + while cursor < bytes.len() && (bytes[cursor].is_ascii_whitespace() || bytes[cursor] == b'/') + { + cursor += 1; + } + let name_start = cursor; + while cursor < bytes.len() + && !bytes[cursor].is_ascii_whitespace() + && !matches!(bytes[cursor], b'=' | b'/' | b'>') + { + cursor += 1; + } + if name_start == cursor { + cursor += 1; + continue; + } + let name = &attributes[name_start..cursor]; + + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + if bytes.get(cursor) != Some(&b'=') { + continue; + } + cursor += 1; + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + + let quote = *bytes.get(cursor)?; + if !matches!(quote, b'\'' | b'"') { + return None; + } + cursor += 1; + let value_start = cursor; + while cursor < bytes.len() && bytes[cursor] != quote { + cursor += 1; + } + if cursor == bytes.len() { + return None; + } + let value = &attributes[value_start..cursor]; + cursor += 1; + if name == wanted { + return Some(value); + } + } + + None +} + +fn parse_download_href(href: &str) -> Option { + let path = href.strip_prefix(DOWNLOAD_PREFIX)?; + let (book_id, query) = path.split_once("/download?fileId=")?; + if book_id.is_empty() || !book_id.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + + let file_id = match query.split_once('&') { + Some((file_id, extra_params)) + if !extra_params.is_empty() && !extra_params.split('&').any(str::is_empty) => + { + file_id + } + Some(_) => return None, + None => query, + }; + if file_id.is_empty() || !file_id.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + + Some(BookorbitIds { + book_id: book_id.parse().ok()?, + file_id: file_id.parse().ok()?, + }) +} + +fn xml_unescape(text: &str) -> String { + let mut output = String::with_capacity(text.len()); + let mut cursor = 0; + + while let Some(offset) = text[cursor..].find('&') { + let ampersand = cursor + offset; + output.push_str(&text[cursor..ampersand]); + let entity_start = ampersand + 1; + let Some(end_offset) = text[entity_start..].find(';') else { + output.push_str(&text[ampersand..]); + return output; + }; + let entity_end = entity_start + end_offset; + let entity = &text[entity_start..entity_end]; + if let Some(character) = decode_entity(entity) { + output.push(character); + } else { + output.push_str(&text[ampersand..=entity_end]); + } + cursor = entity_end + 1; + } + + output.push_str(&text[cursor..]); + output +} + +fn decode_entity(entity: &str) -> Option { + match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => entity + .strip_prefix("#x") + .or_else(|| entity.strip_prefix("#X")) + .and_then(|digits| u32::from_str_radix(digits, 16).ok()) + .or_else(|| { + entity + .strip_prefix('#') + .and_then(|digits| digits.parse().ok()) + }) + .and_then(char::from_u32), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const REAL_SHAPE_FEED: &str = r#" + + urn:bookorbit:catalog + BookOrbit Catalog + + The Daily EPUB — 2026-09-05 (X4) + urn:bookorbit:book:410 + + + + The Daily EPUB — 2026-09-05 + urn:bookorbit:book:411 + + +"#; + + fn date() -> Date { + "2026-09-05".parse().expect("date") + } + + fn feed(entries: &str) -> String { + format!(r#"{entries}"#) + } + + #[test] + fn exact_title_match_returns_the_right_ids() { + let xml = feed( + r#"Another book + The Daily EPUB — 2026-09-05"#, + ); + + assert_eq!( + select_issue_entry(&xml, "The Daily EPUB — 2026-09-05", date()).unwrap(), + Some(BookorbitIds { + book_id: 31, + file_id: 47 + }) + ); + } + + #[test] + fn x4_entry_listed_first_is_skipped_for_standard() { + assert_eq!( + select_issue_entry(REAL_SHAPE_FEED, "The Daily EPUB — 2026-09-05", date()).unwrap(), + Some(BookorbitIds { + book_id: 411, + file_id: 902 + }) + ); + } + + #[test] + fn hyphen_form_fallback_works_without_em_dash_title() { + let xml = feed( + r#"The Daily EPUB - 2026-09-05"#, + ); + + assert_eq!( + select_issue_entry(&xml, "The Daily EPUB — 2026-09-05", date()).unwrap(), + Some(BookorbitIds { + book_id: 52, + file_id: 81 + }) + ); + } + + #[test] + fn exact_title_outranks_an_earlier_fallback() { + let xml = feed( + r#"The Daily EPUB - 2026-09-05 + The Daily EPUB — 2026-09-05"#, + ); + + assert_eq!( + select_issue_entry(&xml, "The Daily EPUB — 2026-09-05", date()).unwrap(), + Some(BookorbitIds { + book_id: 3, + file_id: 4 + }) + ); + } + + #[test] + fn no_matching_entry_returns_none() { + let xml = feed( + r#"Unrelated"#, + ); + + assert_eq!( + select_issue_entry(&xml, "The Daily EPUB — 2026-09-05", date()).unwrap(), + None + ); + assert_eq!( + select_issue_entry(&feed(""), "The Daily EPUB — 2026-09-05", date()).unwrap(), + None + ); + } + + #[test] + fn malformed_acquisition_href_is_an_error_for_a_match() { + let xml = feed( + r#"The Daily EPUB — 2026-09-05"#, + ); + + assert!(matches!( + select_issue_entry(&xml, "The Daily EPUB — 2026-09-05", date()), + Err(BookorbitError::Malformed(_)) + )); + } + + #[test] + fn title_entities_are_unescaped_before_comparison() { + let xml = feed( + r#"Books & News — 2026-09-05"#, + ); + + assert_eq!( + select_issue_entry(&xml, "Books & News — 2026-09-05", date()).unwrap(), + Some(BookorbitIds { + book_id: 12, + file_id: 13 + }) + ); + } + + #[test] + fn attributed_entry_and_reordered_link_attributes_parse() { + let xml = feed( + r#"The Daily EPUB — 2026-09-05"#, + ); + + assert_eq!( + select_issue_entry(&xml, "The Daily EPUB — 2026-09-05", date()).unwrap(), + Some(BookorbitIds { + book_id: 63, + file_id: 64 + }) + ); + } + + #[test] + fn reader_url_formats_the_reader_route() { + assert_eq!( + reader_url( + "https://bookorbit.example", + BookorbitIds { + book_id: 14, + file_id: 29 + } + ), + "https://bookorbit.example/read/14/29" + ); + } + + #[test] + fn body_without_feed_is_malformed() { + assert!(matches!( + select_issue_entry( + "not an Atom feed", + "The Daily EPUB — 2026-09-05", + date() + ), + Err(BookorbitError::Malformed(_)) + )); + } +} diff --git a/src/lib.rs b/src/lib.rs index 915b00a..e193a30 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ //! ``` pub mod auth; +pub mod bookorbit; pub mod comments; pub mod config; pub mod curate;