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`, `/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}/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 /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/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. | | `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. | | `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.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. | | `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.enabled` | `true` | Set `false` to skip the converter entirely. |
| `xtc.command` | `node` | Converter executable. | | `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>`). | | `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 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. 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 ### Why XTC is not in the feed
XTC files are still generated and still land in `publish.xtc_dir` — they are just 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. # routes remain public. A signed-in web user can download without Basic auth.
# basic_auth_user = "daily" # basic_auth_user = "daily"
# basic_auth_pass = "..." # 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 publish: PublishConfig,
pub xtc: XtcConfig, pub xtc: XtcConfig,
pub server: ServerConfig, pub server: ServerConfig,
pub bookorbit: BookorbitConfig,
} }
impl Default for Config { impl Default for Config {
@@ -106,6 +107,7 @@ impl Default for Config {
publish: PublishConfig::default(), publish: PublishConfig::default(),
xtc: XtcConfig::default(), xtc: XtcConfig::default(),
server: ServerConfig::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 /// Config keys that moved from `[deepseek]` to `[llm]`; anywhere else they are
/// a stale-configuration error. /// a stale-configuration error.
const LLM_ROLE_KEYS: &[&str] = &[ const LLM_ROLE_KEYS: &[&str] = &[
@@ -1267,6 +1323,11 @@ mod tests {
assert_eq!(c.curation.feedback.verdicts_in_prompt, 60); assert_eq!(c.curation.feedback.verdicts_in_prompt, 60);
assert_eq!(c.xtc.format, XtcFormat::Xtch); assert_eq!(c.xtc.format, XtcFormat::Xtch);
assert_eq!(c.curation.sections.len(), 8); 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(); c.validate().unwrap();
} }
@@ -1296,6 +1357,7 @@ mod tests {
jail.set_env("DAILY_EPUB_MINIFLUX__API_KEY", "secret-token"); jail.set_env("DAILY_EPUB_MINIFLUX__API_KEY", "secret-token");
jail.set_env("DAILY_EPUB_TARGET_ARTICLE_COUNT", "12"); jail.set_env("DAILY_EPUB_TARGET_ARTICLE_COUNT", "12");
jail.set_env("DAILY_EPUB_SERVER__HMAC_SECRET", "hunter2"); 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__API_KEY", "voyage-key");
jail.set_env("DAILY_EPUB_VOYAGE__ENABLED", "false"); jail.set_env("DAILY_EPUB_VOYAGE__ENABLED", "false");
jail.set_env("DAILY_EPUB_PROVIDERS__GEMINI__API_KEY", "gemini-key"); 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.miniflux.api_key.as_deref(), Some("secret-token"));
assert_eq!(c.target_article_count, 12); assert_eq!(c.target_article_count, 12);
assert_eq!(c.server.hmac_secret.as_deref(), Some("hunter2")); assert_eq!(c.server.hmac_secret.as_deref(), Some("hunter2"));
assert_eq!(c.bookorbit.opds_pass.as_deref(), Some("orbit-secret"));
// untouched default // untouched default
assert_eq!(c.retention_days, 21); assert_eq!(c.retention_days, 21);
assert_eq!(c.timezone, "America/New_York"); 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] #[test]
fn explicit_missing_path_is_an_error() { fn explicit_missing_path_is_an_error() {
assert!(matches!( assert!(matches!(
+67 -1
View File
@@ -71,6 +71,8 @@ pub struct IssueRow {
pub front_page_html: Option<String>, pub front_page_html: Option<String>,
pub report_json: Option<String>, pub report_json: Option<String>,
pub issue_json: Option<String>, pub issue_json: Option<String>,
pub bookorbit_book_id: Option<i64>,
pub bookorbit_file_id: Option<i64>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -537,7 +539,8 @@ impl Db {
pub async fn issue_by_date(&self, date: Date) -> Result<Option<IssueRow>> { pub async fn issue_by_date(&self, date: Date) -> Result<Option<IssueRow>> {
let row = sqlx::query( let row = sqlx::query(
"SELECT date, issue_number, generated_at, epub_path, x4_path, xtc_path, "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 = ?", FROM issues WHERE date = ?",
) )
.bind(date.to_string()) .bind(date.to_string())
@@ -546,6 +549,25 @@ impl Db {
row.as_ref().map(issue_from_row).transpose() 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. /// Issue archive rows, newest first. A non-positive limit means all rows.
pub async fn issue_dates(&self, limit: Option<i64>) -> Result<Vec<IssueListRow>> { pub async fn issue_dates(&self, limit: Option<i64>) -> Result<Vec<IssueListRow>> {
let rows = sqlx::query( 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"), front_page_html: row.get("front_page_html"),
report_json: row.get("report_json"), report_json: row.get("report_json"),
issue_json: row.get("issue_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)); 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) { async fn record_run(db: &Db, date: Date, started: &str, deepseek: f64, anthropic: f64) {
use crate::report::{ProviderUsage, RunReport}; use crate::report::{ProviderUsage, RunReport};
let started_at = ts(started); let started_at = ts(started);
+1
View File
@@ -13,6 +13,7 @@
//! ``` //! ```
pub mod auth; pub mod auth;
pub mod bookorbit;
pub mod comments; pub mod comments;
pub mod config; pub mod config;
pub mod curate; pub mod curate;
+22 -1
View File
@@ -217,10 +217,12 @@ const OPTIONAL_KEYS: &[(&str, FieldKind)] = &[
("server.hmac_secret", FieldKind::Secret), ("server.hmac_secret", FieldKind::Secret),
("server.basic_auth_user", FieldKind::Text), ("server.basic_auth_user", FieldKind::Text),
("server.basic_auth_pass", FieldKind::Secret), ("server.basic_auth_pass", FieldKind::Secret),
("bookorbit.opds_user", FieldKind::Text),
("bookorbit.opds_pass", FieldKind::Secret),
("xtc.settings", FieldKind::Path), ("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] = &[ const PATH_KEYS: &[&str] = &[
"database_path", "database_path",
@@ -256,6 +258,7 @@ const GROUP_ORDER: &[&str] = &[
"xtc", "xtc",
"server", "server",
"miniflux", "miniflux",
"bookorbit",
]; ];
/// Help text per key, seeded from the README configuration table and the /// 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.login_window_minutes", "Length of the login throttle window."),
("server.jobs_enabled", "Allow the dashboard to start the fixed systemd job catalogue."), ("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)."), ("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). /// `DAILY_EPUB_` + the path upper-cased with `.` → `__` (§13.1 item 2).
@@ -1654,6 +1662,8 @@ mod tests {
"server.hmac_secret", "server.hmac_secret",
"xtc.settings", "xtc.settings",
"server.basic_auth_user", "server.basic_auth_user",
"bookorbit.opds_user",
"bookorbit.opds_pass",
] { ] {
field(&groups, path); field(&groups, path);
} }
@@ -1684,6 +1694,7 @@ mod tests {
"xtc", "xtc",
"server", "server",
"miniflux", "miniflux",
"bookorbit",
] ]
); );
let anthropic = groups let anthropic = groups
@@ -1799,6 +1810,7 @@ mod tests {
config.voyage.api_key = Some("hunter2-voyage".into()); config.voyage.api_key = Some("hunter2-voyage".into());
config.server.hmac_secret = Some("hunter2-hmac".into()); config.server.hmac_secret = Some("hunter2-hmac".into());
config.server.basic_auth_pass = Some("hunter2-basic".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") { if let Some(provider) = config.providers.get_mut("deepseek") {
provider.api_key = Some("hunter2-deepseek".into()); provider.api_key = Some("hunter2-deepseek".into());
} }
@@ -1808,6 +1820,7 @@ mod tests {
"voyage.api_key", "voyage.api_key",
"server.hmac_secret", "server.hmac_secret",
"server.basic_auth_pass", "server.basic_auth_pass",
"bookorbit.opds_pass",
"providers.deepseek.api_key", "providers.deepseek.api_key",
"providers.anthropic.api_key", "providers.anthropic.api_key",
] { ] {
@@ -2405,6 +2418,11 @@ mod tests {
async fn settings_pages_render_save_and_show_hand_edits() { async fn settings_pages_render_save_and_show_hand_edits() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = copy_example(dir.path()); 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 (_db_dir, state, _) = test_state(Some(&path)).await;
let app = router(state.clone()); let app = router(state.clone());
let cookie = login(&app).await; let cookie = login(&app).await;
@@ -2421,12 +2439,15 @@ mod tests {
"id=\"curation.ranking\"", "id=\"curation.ranking\"",
"id=\"curation.ranking.weights.preliminary\"", "id=\"curation.ranking.weights.preliminary\"",
"id=\"providers.gemini\"", "id=\"providers.gemini\"",
"id=\"bookorbit\"",
] { ] {
assert!(html.contains(anchor), "{anchor}"); assert!(html.contains(anchor), "{anchor}");
} }
assert!(html.contains("name=\"curation.ranking.deep_keep\"")); assert!(html.contains("name=\"curation.ranking.deep_keep\""));
assert!(html.contains("renormalized")); assert!(html.contains("renormalized"));
assert!(html.contains("not set")); 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")); assert!(html.contains("/dashboard/settings/history"));
let saved = app let saved = app