Add [bookorbit] config, settings section and cached reader ids
New BookorbitConfig section (enabled, URLs, OPDS user; password via DAILY_EPUB_BOOKORBIT__OPDS_PASS), its settings-dashboard group and secret masking, migration 0005 adding bookorbit_book_id/bookorbit_file_id to issues with Db::set_bookorbit_ids, and README rows for the new keys. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE issues ADD COLUMN bookorbit_book_id INTEGER;
|
||||||
|
ALTER TABLE issues ADD COLUMN bookorbit_file_id INTEGER;
|
||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user