diff --git a/README.md b/README.md index 2b7544d..97a1b31 100644 --- a/README.md +++ b/README.md @@ -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/` 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 ` -o -f ` (plus `-c `). | @@ -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//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 diff --git a/config.example.toml b/config.example.toml index e9c372f..8339ac3 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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) diff --git a/migrations/0005_bookorbit.sql b/migrations/0005_bookorbit.sql new file mode 100644 index 0000000..8985ae1 --- /dev/null +++ b/migrations/0005_bookorbit.sql @@ -0,0 +1,2 @@ +ALTER TABLE issues ADD COLUMN bookorbit_book_id INTEGER; +ALTER TABLE issues ADD COLUMN bookorbit_file_id INTEGER; diff --git a/src/config.rs b/src/config.rs index 63a1f54..2e27e19 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, + /// Dedicated BookOrbit OPDS password; supply via + /// `DAILY_EPUB_BOOKORBIT__OPDS_PASS`. + pub opds_pass: Option, +} + +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!( diff --git a/src/db.rs b/src/db.rs index 683f81f..0676264 100644 --- a/src/db.rs +++ b/src/db.rs @@ -71,6 +71,8 @@ pub struct IssueRow { pub front_page_html: Option, pub report_json: Option, pub issue_json: Option, + pub bookorbit_book_id: Option, + pub bookorbit_file_id: Option, } #[derive(Debug, Clone)] @@ -537,7 +539,8 @@ impl Db { pub async fn issue_by_date(&self, date: Date) -> Result> { 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) -> Result> { let rows = sqlx::query( @@ -969,6 +991,8 @@ fn issue_from_row(row: &sqlx::sqlite::SqliteRow) -> Result { 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); diff --git a/src/web/dashboard/settings.rs b/src/web/dashboard/settings.rs index 8a316f2..0ecf3f1 100644 --- a/src/web/dashboard/settings.rs +++ b/src/web/dashboard/settings.rs @@ -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