Merge branch 'bookorbit-b' into bookorbit

This commit is contained in:
2026-09-05 05:20:34 +00:00
8 changed files with 703 additions and 2 deletions
+16
View File
@@ -233,6 +233,7 @@ these writes.
|---|---|---|
| `GET /`, `/issues`, `/issues/{date}`, `/feed.xml` | Public | Latest issue, archive, stripped issue index, and equivalent Atom feed. Signed-in issue views expand to the complete issue. |
| `GET /issues/{date}/articles/{id}`, `/world`, `/behind` | User or admin | Private article, World Briefing, and Behind the paper chapters. |
| `GET /issues/{date}/read` | User or admin | Open the Standard edition in BookOrbit's web reader when the integration is enabled. |
| `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded CSS, JavaScript, and favicon. |
| `GET/POST /login`, `POST /logout` | Public/session | Sign in and out; login attempts are throttled per client IP. |
| `GET /account`, `POST /account/password`, `/account/logout-all` | User or admin | Change the current password or revoke sessions. |
@@ -355,6 +356,11 @@ prints what resolved.
| `editorial.summary_input_tokens` | `3000` | Article text offered to the summary prompt. |
| `publish.epub_dir` | `/srv/bookorbit/libraries/daily-epub` | Both EPUB editions land here by atomic copy, and this is the directory the OPDS feed lists. The editions are distinguished by a `(X4)` tag in **both** the filename and `dc:title` — libraries and OPDS clients list books by title, so the filename alone would make them look identical. Point a BookOrbit watched folder at it if you want its UI too. **Renamed from `bookorbit_dir`**; the old key is a hard config error. |
| `publish.xtc_dir` | `/var/lib/daily-epub/xtc` | XTC artifacts. **Not** listed in the OPDS feed — CrossPoint cannot acquire them — but downloadable at `/files/xtc/<name>` for sideloading. |
| `bookorbit.enabled` | `false` | Enable the signed-in **Read in BookOrbit** integration when both OPDS credentials are set. |
| `bookorbit.public_url` | `https://bookorbit.hallada.net` | Browser-facing BookOrbit base URL. |
| `bookorbit.api_url` | `http://127.0.0.1:3498` | Server-facing BookOrbit base URL used for OPDS lookups. |
| `bookorbit.opds_user` | unset | Dedicated OPDS user created in BookOrbit's Settings → OPDS. |
| `bookorbit.opds_pass` | — | **`DAILY_EPUB_BOOKORBIT__OPDS_PASS`**, environment only. |
| `xtc.enabled` | `true` | Set `false` to skip the converter entirely. |
| `xtc.command` | `node` | Converter executable. |
| `xtc.args` | `["/opt/epub-to-xtc-converter/cli/index.js", "convert"]` | Prefix; the code appends `<input.epub> -o <output> -f <format>` (plus `-c <settings>`). |
@@ -699,6 +705,16 @@ This is deliberately independent of BookOrbit: it needs only the directory, so
BookOrbit is optional, and it puts the day's issue one screen from the X4's home
instead of several clicks down a library tree.
For desktop reading, the signed-in issue page can show a **Read in BookOrbit**
button that opens the Standard edition in BookOrbit's web reader. Create an OPDS
user in BookOrbit under Settings → OPDS, put its name in `config.toml`, put
`DAILY_EPUB_BOOKORBIT__OPDS_PASS` in `/etc/daily-epub/env`, set
`bookorbit.enabled = true`, and restart `daily-epub.service`; no systemd change
is needed because the unit already allows loopback HTTP. Book and file ids are
looked up lazily on the first click and cached on the `issues` row; if BookOrbit
re-indexes a book, `/issues/<date>/read?refresh=1` clears the cache and resolves
the ids again.
### Why XTC is not in the feed
XTC files are still generated and still land in `publish.xtc_dir` — they are just
+7
View File
@@ -220,3 +220,10 @@ journal_lines = 300 # job-page journal tail; valid range 10..=5
# routes remain public. A signed-in web user can download without Basic auth.
# basic_auth_user = "daily"
# basic_auth_pass = "..."
[bookorbit]
enabled = false
public_url = "https://bookorbit.hallada.net" # what the browser opens
api_url = "http://127.0.0.1:3498" # where the server talks OPDS; same host
opds_user = "" # an OPDS user from BookOrbit → Settings → OPDS
# opds_pass: environment only (DAILY_EPUB_BOOKORBIT__OPDS_PASS)
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE issues ADD COLUMN bookorbit_book_id INTEGER;
ALTER TABLE issues ADD COLUMN bookorbit_file_id INTEGER;
+508
View File
@@ -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=<date>`, acquisition
//! links expose `/api/v1/opds/<book_id>/download?fileId=<file_id>`, and browser
//! links use `/read/<book_id>/<file_id>`. 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<Option<BookorbitIds>, 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<Option<BookorbitIds>, BookorbitError> {
if find_open_tag(feed_xml, "feed", 0).is_none() {
return Err(BookorbitError::Malformed(
"response does not contain an Atom <feed> 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("</entry>") else {
break;
};
let body_end = body_start + close_offset;
entries.push(&feed_xml[body_start..body_end]);
cursor = body_end + "</entry>".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!("</{name}>");
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<usize> {
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<Option<BookorbitIds>, 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 + "<link".len()..end];
if attribute_value(attributes, "rel") == Some(ACQUISITION_REL) {
saw_acquisition = true;
if let Some(href) = attribute_value(attributes, "href") {
let href = xml_unescape(href);
if let Some(ids) = parse_download_href(&href) {
return Ok(Some(ids));
}
}
}
cursor = end + 1;
}
if saw_acquisition {
Err(BookorbitError::Malformed(
"qualifying entry has an invalid acquisition href".to_string(),
))
} else {
Ok(None)
}
}
fn attribute_value<'a>(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<BookorbitIds> {
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<char> {
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#"<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>urn:bookorbit:catalog</id>
<title>BookOrbit Catalog</title>
<entry data-source="watch-folder">
<title>The Daily EPUB — 2026-09-05 (X4)</title>
<id>urn:bookorbit:book:410</id>
<link rel="http://opds-spec.org/acquisition" href="/api/v1/opds/410/download?fileId=901" type="application/epub+zip" title="EPUB"/>
</entry>
<entry data-source="watch-folder">
<title>The Daily EPUB — 2026-09-05</title>
<id>urn:bookorbit:book:411</id>
<link rel="http://opds-spec.org/acquisition" href="/api/v1/opds/411/download?fileId=902" type="application/epub+zip" title="EPUB"/>
</entry>
</feed>"#;
fn date() -> Date {
"2026-09-05".parse().expect("date")
}
fn feed(entries: &str) -> String {
format!(r#"<feed xmlns="http://www.w3.org/2005/Atom">{entries}</feed>"#)
}
#[test]
fn exact_title_match_returns_the_right_ids() {
let xml = feed(
r#"<entry><title>Another book</title><link rel="http://opds-spec.org/acquisition" href="/api/v1/opds/1/download?fileId=2"/></entry>
<entry><title>The Daily EPUB — 2026-09-05</title><link rel="http://opds-spec.org/acquisition" href="/api/v1/opds/31/download?fileId=47"/></entry>"#,
);
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#"<entry><title>The Daily EPUB - 2026-09-05</title><link rel="http://opds-spec.org/acquisition" href="/api/v1/opds/52/download?fileId=81"/></entry>"#,
);
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#"<entry><title>The Daily EPUB - 2026-09-05</title><link rel="http://opds-spec.org/acquisition" href="/api/v1/opds/1/download?fileId=2"/></entry>
<entry><title>The Daily EPUB — 2026-09-05</title><link rel="http://opds-spec.org/acquisition" href="/api/v1/opds/3/download?fileId=4"/></entry>"#,
);
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#"<entry><title>Unrelated</title><link rel="http://opds-spec.org/acquisition" href="/api/v1/opds/7/download?fileId=8"/></entry>"#,
);
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#"<entry><title>The Daily EPUB — 2026-09-05</title><link rel="http://opds-spec.org/acquisition" href="/api/v1/opds/nope/download?fileId=8"/></entry>"#,
);
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#"<entry><title>Books &amp; News — 2026-09-05</title><link rel="http://opds-spec.org/acquisition" href="/api/v1/opds/12/download?fileId=13"/></entry>"#,
);
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#"<entry data-index="1"><title type="text">The Daily EPUB — 2026-09-05</title><link title="EPUB" href="/api/v1/opds/63/download?fileId=64&amp;download=true" type="application/epub+zip" rel="http://opds-spec.org/acquisition"/></entry>"#,
);
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(
"<html><body>not an Atom feed</body></html>",
"The Daily EPUB — 2026-09-05",
date()
),
Err(BookorbitError::Malformed(_))
));
}
}
+80
View File
@@ -82,6 +82,7 @@ pub struct Config {
pub publish: PublishConfig,
pub xtc: XtcConfig,
pub server: ServerConfig,
pub bookorbit: BookorbitConfig,
}
impl Default for Config {
@@ -106,6 +107,7 @@ impl Default for Config {
publish: PublishConfig::default(),
xtc: XtcConfig::default(),
server: ServerConfig::default(),
bookorbit: BookorbitConfig::default(),
}
}
}
@@ -728,6 +730,60 @@ impl Default for ServerConfig {
}
}
/// `[bookorbit]` — optional web-reader integration via BookOrbit's OPDS API.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct BookorbitConfig {
/// Whether the signed-in BookOrbit reader integration is enabled.
pub enabled: bool,
/// Base URL opened in the reader's browser.
pub public_url: String,
/// Base URL used for server-side OPDS requests.
pub api_url: String,
/// Dedicated BookOrbit OPDS username.
pub opds_user: Option<String>,
/// Dedicated BookOrbit OPDS password; supply via
/// `DAILY_EPUB_BOOKORBIT__OPDS_PASS`.
pub opds_pass: Option<String>,
}
impl Default for BookorbitConfig {
fn default() -> Self {
Self {
enabled: false,
public_url: "https://bookorbit.hallada.net".into(),
api_url: "http://127.0.0.1:3498".into(),
opds_user: None,
opds_pass: None,
}
}
}
impl BookorbitConfig {
/// Whether the integration is enabled and has non-empty OPDS credentials.
pub fn is_active(&self) -> bool {
self.enabled
&& self
.opds_user
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
&& self
.opds_pass
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
}
/// Browser-facing base URL without trailing slashes.
pub fn public_url(&self) -> &str {
self.public_url.trim_end_matches('/')
}
/// Server-facing API base URL without trailing slashes.
pub fn api_url(&self) -> &str {
self.api_url.trim_end_matches('/')
}
}
/// Config keys that moved from `[deepseek]` to `[llm]`; anywhere else they are
/// a stale-configuration error.
const LLM_ROLE_KEYS: &[&str] = &[
@@ -1267,6 +1323,11 @@ mod tests {
assert_eq!(c.curation.feedback.verdicts_in_prompt, 60);
assert_eq!(c.xtc.format, XtcFormat::Xtch);
assert_eq!(c.curation.sections.len(), 8);
assert!(!c.bookorbit.enabled);
assert_eq!(c.bookorbit.public_url, "https://bookorbit.hallada.net");
assert_eq!(c.bookorbit.api_url, "http://127.0.0.1:3498");
assert!(c.bookorbit.opds_user.is_none());
assert!(c.bookorbit.opds_pass.is_none());
c.validate().unwrap();
}
@@ -1296,6 +1357,7 @@ mod tests {
jail.set_env("DAILY_EPUB_MINIFLUX__API_KEY", "secret-token");
jail.set_env("DAILY_EPUB_TARGET_ARTICLE_COUNT", "12");
jail.set_env("DAILY_EPUB_SERVER__HMAC_SECRET", "hunter2");
jail.set_env("DAILY_EPUB_BOOKORBIT__OPDS_PASS", "orbit-secret");
jail.set_env("DAILY_EPUB_VOYAGE__API_KEY", "voyage-key");
jail.set_env("DAILY_EPUB_VOYAGE__ENABLED", "false");
jail.set_env("DAILY_EPUB_PROVIDERS__GEMINI__API_KEY", "gemini-key");
@@ -1324,6 +1386,7 @@ mod tests {
assert_eq!(c.miniflux.api_key.as_deref(), Some("secret-token"));
assert_eq!(c.target_article_count, 12);
assert_eq!(c.server.hmac_secret.as_deref(), Some("hunter2"));
assert_eq!(c.bookorbit.opds_pass.as_deref(), Some("orbit-secret"));
// untouched default
assert_eq!(c.retention_days, 21);
assert_eq!(c.timezone, "America/New_York");
@@ -1331,6 +1394,23 @@ mod tests {
});
}
#[test]
fn bookorbit_activation_and_url_accessors() {
let mut bookorbit = BookorbitConfig {
enabled: true,
public_url: "https://books.example///".into(),
api_url: "http://127.0.0.1:3498/".into(),
opds_user: Some("reader".into()),
opds_pass: Some("secret".into()),
};
assert!(bookorbit.is_active());
assert_eq!(bookorbit.public_url(), "https://books.example");
assert_eq!(bookorbit.api_url(), "http://127.0.0.1:3498");
bookorbit.opds_pass = Some(" ".into());
assert!(!bookorbit.is_active());
}
#[test]
fn explicit_missing_path_is_an_error() {
assert!(matches!(
+67 -1
View File
@@ -71,6 +71,8 @@ pub struct IssueRow {
pub front_page_html: Option<String>,
pub report_json: Option<String>,
pub issue_json: Option<String>,
pub bookorbit_book_id: Option<i64>,
pub bookorbit_file_id: Option<i64>,
}
#[derive(Debug, Clone)]
@@ -537,7 +539,8 @@ impl Db {
pub async fn issue_by_date(&self, date: Date) -> Result<Option<IssueRow>> {
let row = sqlx::query(
"SELECT date, issue_number, generated_at, epub_path, x4_path, xtc_path,
front_page_html, report_json, issue_json
front_page_html, report_json, issue_json,
bookorbit_book_id, bookorbit_file_id
FROM issues WHERE date = ?",
)
.bind(date.to_string())
@@ -546,6 +549,25 @@ impl Db {
row.as_ref().map(issue_from_row).transpose()
}
/// Store or clear the BookOrbit reader ids cached for an issue date.
pub async fn set_bookorbit_ids(&self, date: Date, ids: Option<(i64, i64)>) -> Result<()> {
let (book_id, file_id) = match ids {
Some((book_id, file_id)) => (Some(book_id), Some(file_id)),
None => (None, None),
};
sqlx::query(
"UPDATE issues
SET bookorbit_book_id = ?, bookorbit_file_id = ?
WHERE date = ?",
)
.bind(book_id)
.bind(file_id)
.bind(date.to_string())
.execute(&self.pool)
.await?;
Ok(())
}
/// Issue archive rows, newest first. A non-positive limit means all rows.
pub async fn issue_dates(&self, limit: Option<i64>) -> Result<Vec<IssueListRow>> {
let rows = sqlx::query(
@@ -969,6 +991,8 @@ fn issue_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<IssueRow> {
front_page_html: row.get("front_page_html"),
report_json: row.get("report_json"),
issue_json: row.get("issue_json"),
bookorbit_book_id: row.get("bookorbit_book_id"),
bookorbit_file_id: row.get("bookorbit_file_id"),
})
}
@@ -1226,6 +1250,48 @@ mod tests {
assert!(spend.values().all(|usd| *usd == 0.0));
}
#[tokio::test]
async fn bookorbit_ids_round_trip_and_clear() {
let (_dir, db) = temp_db().await;
let date: Date = "2026-08-15".parse().unwrap();
db.upsert_issue(
date,
1,
ts("2026-08-15T05:36:00Z"),
Some("The Daily EPUB - 2026-08-15.epub"),
None,
None,
None,
None,
None,
)
.await
.unwrap();
db.set_bookorbit_ids(date, Some((42, 84))).await.unwrap();
db.upsert_issue(
date,
1,
ts("2026-08-15T05:37:00Z"),
None,
None,
None,
None,
None,
None,
)
.await
.unwrap();
let issue = db.issue_by_date(date).await.unwrap().unwrap();
assert_eq!(issue.bookorbit_book_id, Some(42));
assert_eq!(issue.bookorbit_file_id, Some(84));
db.set_bookorbit_ids(date, None).await.unwrap();
let issue = db.issue_by_date(date).await.unwrap().unwrap();
assert_eq!(issue.bookorbit_book_id, None);
assert_eq!(issue.bookorbit_file_id, None);
}
async fn record_run(db: &Db, date: Date, started: &str, deepseek: f64, anthropic: f64) {
use crate::report::{ProviderUsage, RunReport};
let started_at = ts(started);
+1
View File
@@ -13,6 +13,7 @@
//! ```
pub mod auth;
pub mod bookorbit;
pub mod comments;
pub mod config;
pub mod curate;
+22 -1
View File
@@ -217,10 +217,12 @@ const OPTIONAL_KEYS: &[(&str, FieldKind)] = &[
("server.hmac_secret", FieldKind::Secret),
("server.basic_auth_user", FieldKind::Text),
("server.basic_auth_pass", FieldKind::Secret),
("bookorbit.opds_user", FieldKind::Text),
("bookorbit.opds_pass", FieldKind::Secret),
("xtc.settings", FieldKind::Path),
];
const SECRET_SUFFIXES: &[&str] = &["api_key", "hmac_secret", "basic_auth_pass"];
const SECRET_SUFFIXES: &[&str] = &["api_key", "hmac_secret", "basic_auth_pass", "opds_pass"];
const PATH_KEYS: &[&str] = &[
"database_path",
@@ -256,6 +258,7 @@ const GROUP_ORDER: &[&str] = &[
"xtc",
"server",
"miniflux",
"bookorbit",
];
/// Help text per key, seeded from the README configuration table and the
@@ -367,6 +370,11 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[
("server.login_window_minutes", "Length of the login throttle window."),
("server.jobs_enabled", "Allow the dashboard to start the fixed systemd job catalogue."),
("server.journal_lines", "Journal lines shown on a dashboard job page (10-5000)."),
("bookorbit.enabled", "Enable the signed-in Read in BookOrbit integration when OPDS credentials are also set."),
("bookorbit.public_url", "Base URL opened in the browser for BookOrbit's web reader."),
("bookorbit.api_url", "Base URL used by the server for BookOrbit OPDS requests; usually the loopback address."),
("bookorbit.opds_user", "Dedicated OPDS user created in BookOrbit Settings → OPDS."),
("bookorbit.opds_pass", "Password for bookorbit.opds_user. Environment only."),
];
/// `DAILY_EPUB_` + the path upper-cased with `.` → `__` (§13.1 item 2).
@@ -1654,6 +1662,8 @@ mod tests {
"server.hmac_secret",
"xtc.settings",
"server.basic_auth_user",
"bookorbit.opds_user",
"bookorbit.opds_pass",
] {
field(&groups, path);
}
@@ -1684,6 +1694,7 @@ mod tests {
"xtc",
"server",
"miniflux",
"bookorbit",
]
);
let anthropic = groups
@@ -1799,6 +1810,7 @@ mod tests {
config.voyage.api_key = Some("hunter2-voyage".into());
config.server.hmac_secret = Some("hunter2-hmac".into());
config.server.basic_auth_pass = Some("hunter2-basic".into());
config.bookorbit.opds_pass = Some("hunter2-bookorbit".into());
if let Some(provider) = config.providers.get_mut("deepseek") {
provider.api_key = Some("hunter2-deepseek".into());
}
@@ -1808,6 +1820,7 @@ mod tests {
"voyage.api_key",
"server.hmac_secret",
"server.basic_auth_pass",
"bookorbit.opds_pass",
"providers.deepseek.api_key",
"providers.anthropic.api_key",
] {
@@ -2405,6 +2418,11 @@ mod tests {
async fn settings_pages_render_save_and_show_hand_edits() {
let dir = tempfile::tempdir().unwrap();
let path = copy_example(dir.path());
let with_secret = std::fs::read_to_string(&path).unwrap().replace(
"# opds_pass: environment only (DAILY_EPUB_BOOKORBIT__OPDS_PASS)",
"opds_pass = \"hunter2-bookorbit\"",
);
std::fs::write(&path, with_secret).unwrap();
let (_db_dir, state, _) = test_state(Some(&path)).await;
let app = router(state.clone());
let cookie = login(&app).await;
@@ -2421,12 +2439,15 @@ mod tests {
"id=\"curation.ranking\"",
"id=\"curation.ranking.weights.preliminary\"",
"id=\"providers.gemini\"",
"id=\"bookorbit\"",
] {
assert!(html.contains(anchor), "{anchor}");
}
assert!(html.contains("name=\"curation.ranking.deep_keep\""));
assert!(html.contains("renormalized"));
assert!(html.contains("not set"));
assert!(html.contains("in the config file — move it to the env file"));
assert!(!html.contains("hunter2-bookorbit"));
assert!(html.contains("/dashboard/settings/history"));
let saved = app