Merge branch 'bookorbit': Read in BookOrbit link on the issue page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
This commit is contained in:
2026-09-05 05:20:34 +00:00
co-authored by Claude Fable 5.1
13 changed files with 1156 additions and 19 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)
@@ -0,0 +1,106 @@
# "Read in BookOrbit" link on the issue page
**Date:** 2026-09-05
**Repository:** `thallada/the-daily-epub`
**Status:** small implementation plan, ready to execute
**Builds on:** `docs/plans/2026-09-03-web-dashboard.md` (the signed-in issue page this adds a button to)
Written for a fresh implementation agent. Every fact about BookOrbit below was verified on 2026-09-05 against its source (`github.com/bookorbit/bookorbit`, `HEAD`) and against the live instance on this host. Do not re-derive them; do re-check anything marked *assumption*.
---
## 1. Goal
On the signed-in issue page (`src/web/templates/issue_full.html`), next to the existing **Download EPUB / X4 EPUB / XTC** buttons, add a **Read in BookOrbit** button that opens the *normal* (Standard) edition of that issue in BookOrbit's in-browser EPUB reader at `https://bookorbit.hallada.net`. The operator reads on desktop through this link instead of downloading.
Out of scope: the X4 edition, the public (anonymous) issue page, the OPDS feed, the EPUB itself.
## 2. Verified facts
### About this repo
- The pipeline does **not** upload to BookOrbit. `publish::publish_issue` atomically copies both EPUB editions into `publish.epub_dir` (`/home/thallada/bookorbit/books/daily-epub` on the host); BookOrbit's *watched folder* scanner picks them up later. Nothing in the run ever learns a BookOrbit id.
- BookOrbit runs on the same host as Docker (`~/bookorbit/docker-compose.yml`): app container `bookorbit-app` published on `127.0.0.1:3498`, Postgres in `bookorbit-db` with **no published port**, and the `daily-epub` service user has no Docker socket access. Reading BookOrbit's database directly is therefore not an option.
- Issue titles: `Issue::title()` is `The Daily EPUB — <date>` (em dash), and `title_for(Edition::Standard)` is that exact string while `title_for(Edition::X4)` appends ` (X4)` (`src/types.rs` ~531544). Filenames use a hyphen instead: `publish::issue_filename(date, edition, "epub")``The Daily EPUB - <date>.epub` / `... (X4).epub`.
- The signed-in issue view is built in `src/web/issue.rs`: `IssueView` (line ~33) carries `downloads: Vec<Download>`, assembled around line 258 from the `issues` row (`row.epub_path`, `row.x4_path`, `row.xtc_path`). The template renders them at `src/web/templates/issue_full.html:6` as `<a class="btn" href="{{ download.href }}">Download …</a>`.
- Signed-in issue routes live in the `full_issues` router in `src/web/mod.rs` (~line 659: `/issues/{date}/articles/{article_id}`, `/issues/{date}/world`, `/issues/{date}/behind`, behind `login_required!` and `map_forbidden`); public routes (`/issues`, `/issues/{date}`) are on the root router ~line 682. Put the new route in **`full_issues`**.
- `issues` table (`migrations/0001_init.sql:70`): `date TEXT PK, issue_number, generated_at, epub_path, x4_path, xtc_path, front_page_html, report_json` (+ `issue_json` added later). Migrations run via `sqlx::migrate!("./migrations")` (`src/db.rs:24`); latest file is `0004_web.sql`, so the new one is `0005_bookorbit.sql`. `Db::issue_by_date(date) -> Option<IssueRow>` is at `src/db.rs:537`; `upsert_issue` at `:496` (do not touch it, ids are resolved lazily, see §3).
- Config: sections are `#[serde(deny_unknown_fields, default)]` structs hung off `Config` (`src/config.rs:8285`, e.g. `pub server: ServerConfig`), with `Default` impls. Env overrides are `DAILY_EPUB_<SECTION>__<KEY>`. `config.example.toml` documents every key; the README has a configuration table (~line 350).
- The settings dashboard (`src/web/dashboard/settings.rs`) has a **hardcoded section registry** (`const` list around line 248259 ending `"publish", "xtc", "server", "miniflux"`), a secret-field list (`("server.basic_auth_pass", FieldKind::Secret)` ~line 219, plus `SECRET_SUFFIXES` at ~223), per-key help text (~line 360), and a test asserting the section list (~line 1683). A new section must be added to all of these or the settings page/test breaks.
- Shared HTTP client: `crate::http::build_client(timeout)` (`src/http.rs:14`), rustls + gzip, no cookies. reqwest 0.13 with the `query` and `json` features is already a dependency; **no XML parser is in the tree** — the OPDS response is tiny and regular, so either add `quick-xml` or match with a small hand parser (see §4).
### About BookOrbit
- Web reader route (client router, `client/src/router/index.ts`): **`/read/:bookId/:fileId`**. Both ids are required numeric integers. Book detail page is `/book/:bookId`.
- An unauthenticated visit to `/read/...` serves the SPA shell (200) and the client auth guard redirects to `/login?redirect=<fullPath>`, returning to the reader after sign-in. So the link can point straight at the reader.
- OPDS (server `server/src/modules/opds/opds.service.ts`, `BASE = '/api/v1/opds'`):
- Auth is **HTTP Basic** against a dedicated *OPDS user* created in BookOrbit's settings (separate static credentials, not the web login). Verified live: `GET http://127.0.0.1:3498/api/v1/opds/catalog?q=Daily``401`, `www-authenticate: Basic realm="bookorbit OPDS"`.
- Search: `GET /api/v1/opds/catalog?q=<terms>` (Atom acquisition feed; server-side `ILIKE` on title, accent-insensitive). Paged with `?page=N`.
- Each `<entry>` contains `<title>…</title>`, `<id>urn:bookorbit:book:<bookId></id>`, and one acquisition link per file:
`<link rel="http://opds-spec.org/acquisition" href="/api/v1/opds/<bookId>/download?fileId=<fileId>" type="application/epub+zip" title="EPUB"/>`.
**That href carries both numbers the reader URL needs.** Each daily issue edition is its own book with a single EPUB file.
- The JSON API (`/api/v1/books/search?q=`, `/api/v1/books/:id` with `files[].id`) exists too but needs a JWT from `/auth/login` (5/min throttle, refresh cookie). Not worth it; OPDS is enough.
- Book titles come from the EPUB `dc:title`, so the Standard edition is titled `The Daily EPUB — 2026-09-05` and the X4 one `The Daily EPUB — 2026-09-05 (X4)`. *Assumption:* BookOrbit's metadata fetcher has not renamed them; the matcher in §4 tolerates a fallback to the filename form.
## 3. Design decisions (settled)
| Topic | Decision | Why |
|---|---|---|
| Where ids are resolved | **Lazily, on click**, by a new redirect route `GET /issues/{date}/read` in the signed-in router. | BookOrbit indexes the folder some time after the run ends, so the id does not exist at publish time. Rendering the issue page must stay free of cross-service calls (see perf work in `main` 17a8804 and earlier). |
| Caching | Two nullable columns on `issues`: `bookorbit_book_id INTEGER`, `bookorbit_file_id INTEGER`. Filled on first successful lookup; later clicks redirect from the cache with no network call. | One OPDS call per issue, ever. |
| Stale cache | If BookOrbit answers the *reader page*, we can't tell from a redirect. Instead: `GET /issues/{date}/read?refresh=1` clears the cached ids and re-resolves. Expose it as a tiny "wrong book?" link only if trivial; otherwise document the query param in the README. | Books can be deleted/re-added in BookOrbit, changing ids. |
| Not indexed yet | Route returns **503** with the existing error template (`src/web/templates/error.html`), message "BookOrbit has not indexed this issue yet. Try again in a minute." Nothing cached. | Honest, cheap, retry-friendly. |
| Which edition | Standard only. The entry whose `<title>` equals `issue.title_for(Edition::Standard)` **exactly**; entries ending in ` (X4)` are never chosen. | The whole point is desktop reading. |
| Auth to BookOrbit | HTTP Basic with an OPDS user's credentials from config. | Static creds, no token lifecycle; same mechanism KOReader already uses. |
| Feature flag | Whole feature off unless `[bookorbit] enabled = true` **and** the three credentials/URL keys are set. Button and route are absent when off. | Repo stays usable without BookOrbit, as today. |
| Link target | `<public_url>/read/<bookId>/<fileId>`, `public_url` from config (`https://bookorbit.hallada.net`), opened with `target="_blank" rel="noopener"`. | BookOrbit's own login redirect handles the signed-out case. |
## 4. Implementation steps
1. **Config** (`src/config.rs`, `config.example.toml`, README table):
```toml
[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)
```
`BookorbitConfig { enabled: bool, public_url: String, api_url: String, opds_user: Option<String>, opds_pass: Option<String> }` with `deny_unknown_fields, default`. Add `pub bookorbit: BookorbitConfig` to `Config` and its `Default`. Add a `fn is_active(&self) -> bool` (enabled + user + pass non-empty). Validate URLs have no trailing slash (or trim).
2. **Settings dashboard** (`src/web/dashboard/settings.rs`): add `"bookorbit"` to the section list, `("bookorbit.opds_pass", FieldKind::Secret)` (and `"opds_pass"` to `SECRET_SUFFIXES`), help text for the five keys, update the section-list test. Copy the pattern used for `server.basic_auth_pass`.
3. **Migration** `migrations/0005_bookorbit.sql`:
```sql
ALTER TABLE issues ADD COLUMN bookorbit_book_id INTEGER;
ALTER TABLE issues ADD COLUMN bookorbit_file_id INTEGER;
```
Extend `IssueRow` and `issue_by_date`'s `SELECT` with the two columns; add `Db::set_bookorbit_ids(date, Option<(i64, i64)>)` (`None` clears).
4. **OPDS client** — new module `src/bookorbit.rs` (~100 lines):
- `pub async fn find_issue(client: &reqwest::Client, cfg: &BookorbitConfig, issue_title: &str, date: Date) -> Result<Option<(i64, i64)>>`.
- `GET {api_url}/api/v1/opds/catalog?q={date}` with `.basic_auth(user, Some(pass))` and `Accept: application/atom+xml`. Searching by the ISO date string avoids em-dash/hyphen and ILIKE-escaping questions, and returns at most the two editions.
- Parse entries: split on `<entry>`…`</entry>`, take `<title>` (XML-unescape `&amp;` etc.), and the first acquisition `href` matching `^/api/v1/opds/(\d+)/download\?fileId=(\d+)$`. Choose the entry whose title equals `issue_title` exactly; if none, accept the title equal to the filename stem `The Daily EPUB - <date>` (hyphen form); never accept a title ending in `(X4)`. Prefer `quick-xml` if you'd rather not hand-roll; either is fine, keep it small and unit-tested against a fixture built from the real XML shape in §2.
- Map `401/403` to a clear error ("BookOrbit rejected the OPDS credentials"), connection errors to "BookOrbit unreachable". Timeout 5 s via `http::build_client`.
- Log at `info` on first resolution with both ids.
5. **Route** `GET /issues/{date}/read` (`src/web/issue.rs`, registered in `src/web/mod.rs` next to `/issues/{date}/behind`, same session guard as the other signed-in issue routes):
- 404 if the feature is inactive or the issue row is missing.
- If `refresh=1`, clear cached ids first.
- Cached ids → `303 See Other` to `{public_url}/read/{book}/{file}`.
- Else call `bookorbit::find_issue`; on `Some`, store and redirect; on `None`, 503 with the "not indexed yet" message; on `Err`, 502 with the error text (operator-only page, so the text can be specific).
- Hold a `reqwest::Client` in `WebState` (`src/web/mod.rs:144`) or build one per call; per-call is acceptable at this frequency.
6. **Template**: add `read_href: Option<String>` to `IssueView` (set to `/issues/{date}/read` when the feature is active and `row.epub_path` is present, i.e. only alongside a real Standard EPUB). In `issue_full.html:6`, render `<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>` **before** the download buttons, inside the same flex row, and keep the row visible when it is the only button.
7. **Tests**: unit tests for the XML matcher (exact title, X4 skipped, hyphen fallback, no match, malformed href); a `src/web` test that the route is 404 when inactive and 303 to the right URL when ids are cached (no network). Follow `downloads_are_listed_only_while_the_files_exist` (`src/web/issue.rs:~2059`) for the fixture style.
8. **Docs/ops**: README configuration table rows for `[bookorbit]`; a note under Delivery: create an OPDS user in BookOrbit (Settings → OPDS), put its name in `config.toml` and the password in `/etc/daily-epub/env` as `DAILY_EPUB_BOOKORBIT__OPDS_PASS`, restart `daily-epub.service`. No systemd change is needed: `systemd/daily-epub.service` sets only `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX` and no `IPAddressDeny`, so loopback HTTP to port 3498 is allowed.
## 5. Acceptance
- With the section absent or `enabled = false`: no button, `/issues/<date>/read` → 404, all existing tests pass.
- With valid OPDS credentials: first click on today's issue → 303 to `https://bookorbit.hallada.net/read/<n>/<m>` and the `issues` row now has both ids; second click makes no request to BookOrbit (check the journal).
- Clicking before BookOrbit has scanned the new file → 503 "not indexed yet", nothing cached, later click succeeds.
- The X4 edition is never the target even though it matches the same search.
- `cargo fmt`, `cargo clippy`, `cargo test` clean.
+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;
+8 -1
View File
@@ -526,10 +526,17 @@ pub struct IssueMeta {
pub reading_minutes: i64, pub reading_minutes: i64,
} }
/// The issue name for a date, without an edition tag:
/// "The Daily EPUB — 2026-08-15". Shared by [`IssueMeta::title`] and callers
/// that only have a date (the BookOrbit lookup matches OPDS titles against it).
pub fn issue_title(date: Date) -> String {
format!("The Daily EPUB — {date}")
}
impl IssueMeta { impl IssueMeta {
/// The issue's name, without an edition tag: "The Daily EPUB — 2026-08-15". /// The issue's name, without an edition tag: "The Daily EPUB — 2026-08-15".
pub fn title(&self) -> String { pub fn title(&self) -> String {
format!("The Daily EPUB — {}", self.date) issue_title(self.date)
} }
/// `dc:title` for one edition: [`title`](Self::title) plus the edition tag /// `dc:title` for one edition: [`title`](Self::title) plus the edition tag
+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
+316 -8
View File
@@ -1,13 +1,15 @@
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Read; use std::io::Read;
use std::path::{Path as FsPath, PathBuf}; use std::path::{Path as FsPath, PathBuf};
use std::time::Duration;
use anyhow::Context; use anyhow::Context;
use askama::Template; use askama::Template;
use axum::extract::{Extension, Path, State}; use axum::extract::{Extension, Path, Query, State};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Redirect, Response};
use axum_login::tower_sessions::Session; use axum_login::tower_sessions::Session;
use jiff::civil::Date; use jiff::civil::Date;
use serde::Deserialize;
use sqlx::Row; use sqlx::Row;
use crate::db::Db; use crate::db::Db;
@@ -32,6 +34,8 @@ pub struct Download {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct IssueView { pub struct IssueView {
pub issue: Issue, pub issue: Issue,
/// Signed-in BookOrbit redirect route when the Standard EPUB can be downloaded.
pub read_href: Option<String>,
pub downloads: Vec<Download>, pub downloads: Vec<Download>,
pub from_json: bool, pub from_json: bool,
pub world_html: Option<String>, pub world_html: Option<String>,
@@ -255,8 +259,7 @@ pub async fn load(
} else { } else {
recover_world_html(&row, config) recover_world_html(&row, config)
}; };
let downloads = [ let standard_download = download(
(
"EPUB", "EPUB",
row.epub_path.as_deref(), row.epub_path.as_deref(),
Some(config.publish.epub_dir.join(crate::publish::issue_filename( Some(config.publish.epub_dir.join(crate::publish::issue_filename(
@@ -265,8 +268,12 @@ pub async fn load(
"epub", "epub",
))), ))),
"epub", "epub",
), );
( let read_href = (config.bookorbit.is_active() && standard_download.is_some())
.then(|| format!("/issues/{date}/read"));
let downloads = [
standard_download,
download(
"X4 EPUB", "X4 EPUB",
row.x4_path.as_deref(), row.x4_path.as_deref(),
Some(config.publish.epub_dir.join(crate::publish::issue_filename( Some(config.publish.epub_dir.join(crate::publish::issue_filename(
@@ -276,13 +283,14 @@ pub async fn load(
))), ))),
"epub", "epub",
), ),
("XTC", row.xtc_path.as_deref(), None, "xtc"), download("XTC", row.xtc_path.as_deref(), None, "xtc"),
] ]
.into_iter() .into_iter()
.filter_map(|(label, raw, fallback, kind)| download(label, raw, fallback, kind)) .flatten()
.collect(); .collect();
Ok(Some(IssueView { Ok(Some(IssueView {
issue, issue,
read_href,
downloads, downloads,
from_json, from_json,
world_html, world_html,
@@ -881,6 +889,7 @@ struct IssueFullTemplate {
issue_number: i64, issue_number: i64,
stats_line: String, stats_line: String,
front_page_html: String, front_page_html: String,
read_href: Option<String>,
downloads: Vec<Download>, downloads: Vec<Download>,
sections: Vec<FullSection>, sections: Vec<FullSection>,
has_world: bool, has_world: bool,
@@ -1003,6 +1012,7 @@ pub async fn render_full(
issue_number: view.issue.meta.issue_number, issue_number: view.issue.meta.issue_number,
stats_line: view.issue.meta.stats_line(), stats_line: view.issue.meta.stats_line(),
front_page_html: view.issue.editorial.front_page_html.clone(), front_page_html: view.issue.editorial.front_page_html.clone(),
read_href: view.read_href,
downloads: view.downloads, downloads: view.downloads,
sections, sections,
has_world: view.issue.world_briefing.is_some() || view.world_html.is_some(), has_world: view.issue.world_briefing.is_some() || view.world_html.is_some(),
@@ -1186,6 +1196,96 @@ pub async fn behind(
.into_response()) .into_response())
} }
/// Query parameters accepted by the BookOrbit reader redirect.
#[derive(Debug, Default, Deserialize)]
pub struct ReadQuery {
#[serde(default)]
refresh: Option<String>,
}
/// Open the Standard edition of an issue in BookOrbit's browser reader.
pub async fn read(
State(state): State<AppState>,
auth: AuthSession,
Path(date): Path<Date>,
Query(query): Query<ReadQuery>,
) -> Result<Response, WebError> {
let _viewer = auth
.user()
.await
.map(Viewer::from)
.ok_or_else(|| WebError::Unauthenticated {
next: format!("/issues/{date}/read"),
})?;
let config = state.config();
if !config.bookorbit.is_active() {
return Err(WebError::NotFound);
}
let Some(row) = state.db.issue_by_date(date).await? else {
return Err(WebError::NotFound);
};
let refresh = query.refresh.as_deref() == Some("1");
if refresh {
state.db.set_bookorbit_ids(date, None).await?;
} else if let (Some(book_id), Some(file_id)) = (row.bookorbit_book_id, row.bookorbit_file_id) {
let ids = crate::bookorbit::BookorbitIds { book_id, file_id };
return Ok(Redirect::to(&crate::bookorbit::reader_url(
config.bookorbit.public_url(),
ids,
))
.into_response());
}
let client = crate::http::build_client(Duration::from_secs(5))
.map_err(|error| WebError::BadGateway(format!("BookOrbit error: {error}")))?;
let opds_user = config
.bookorbit
.opds_user
.as_deref()
.ok_or(WebError::NotFound)?;
let opds_pass = config
.bookorbit
.opds_pass
.as_deref()
.ok_or(WebError::NotFound)?;
let issue_title = crate::types::issue_title(date);
match crate::bookorbit::find_issue(
&client,
config.bookorbit.api_url(),
opds_user,
opds_pass,
&issue_title,
date,
)
.await
{
Ok(Some(ids)) => {
state
.db
.set_bookorbit_ids(date, Some((ids.book_id, ids.file_id)))
.await?;
tracing::info!(
%date,
book_id = ids.book_id,
file_id = ids.file_id,
"cached BookOrbit issue ids"
);
Ok(Redirect::to(&crate::bookorbit::reader_url(
config.bookorbit.public_url(),
ids,
))
.into_response())
}
Ok(None) => Err(WebError::ServiceUnavailable(
"BookOrbit has not indexed this issue yet. Try again in a minute.".to_string(),
)),
Err(error) => {
tracing::warn!(%date, %error, "BookOrbit issue lookup failed");
Err(WebError::BadGateway(error.to_string()))
}
}
}
fn article_href(date: Date, article_id: ArticleId) -> String { fn article_href(date: Date, article_id: ArticleId) -> String {
format!("/issues/{date}/articles/{article_id}") format!("/issues/{date}/articles/{article_id}")
} }
@@ -2091,6 +2191,214 @@ mod tests {
assert!(!issue.contains("Download XTC")); assert!(!issue.contains("Download XTC"));
} }
#[tokio::test]
async fn bookorbit_read_is_hidden_and_not_found_when_inactive() {
let (_dir, db, source) = seeded_issue(false).await;
crate::web::users::add(&db, "reader", "correct horse battery", false)
.await
.unwrap();
let app = crate::server::router(crate::server::AppState::new(
db,
crate::config::Config::default(),
None,
));
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
let issue = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/issues/{}", source.meta.date))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let issue = response_text(issue).await;
assert!(issue.contains("Download EPUB"));
assert!(!issue.contains("Read in BookOrbit"));
let read = app
.oneshot(
Request::builder()
.uri(format!("/issues/{}/read", source.meta.date))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(read.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn bookorbit_read_button_and_cached_redirect_use_the_public_url() {
let (dir, db, source) = seeded_issue(true).await;
crate::web::users::add(&db, "reader", "correct horse battery", false)
.await
.unwrap();
let epub_dir = dir.path().join("epubs");
std::fs::create_dir(&epub_dir).unwrap();
std::fs::write(
epub_dir.join(crate::publish::issue_filename(
source.meta.date,
Edition::Standard,
"epub",
)),
b"standard epub",
)
.unwrap();
db.set_bookorbit_ids(source.meta.date, Some((12, 34)))
.await
.unwrap();
let mut config = crate::config::Config::default();
config.publish.epub_dir = epub_dir.clone();
config.bookorbit.enabled = true;
config.bookorbit.opds_user = Some("reader".into());
config.bookorbit.opds_pass = Some("secret".into());
config.bookorbit.api_url = "http://127.0.0.1:9".into();
let app = crate::server::router(crate::server::AppState::new(
db.clone(),
config.clone(),
None,
));
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
let issue = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/issues/{}", source.meta.date))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let issue = response_text(issue).await;
assert!(issue.contains("Read in BookOrbit"));
assert!(issue.contains(&format!("href=\"/issues/{}/read\"", source.meta.date)));
let read = app
.oneshot(
Request::builder()
.uri(format!("/issues/{}/read", source.meta.date))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(read.status(), StatusCode::SEE_OTHER);
assert_eq!(
read.headers().get(header::LOCATION).unwrap(),
"https://bookorbit.hallada.net/read/12/34"
);
config.bookorbit.public_url = "https://example.test/".into();
let app = crate::server::router(crate::server::AppState::new(db, config, None));
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
let read = app
.oneshot(
Request::builder()
.uri(format!("/issues/{}/read", source.meta.date))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(read.status(), StatusCode::SEE_OTHER);
assert_eq!(
read.headers().get(header::LOCATION).unwrap(),
"https://example.test/read/12/34"
);
}
#[tokio::test]
async fn bookorbit_read_button_is_hidden_without_the_standard_epub() {
let (dir, db, source) = seeded_issue(true).await;
crate::web::users::add(&db, "reader", "correct horse battery", false)
.await
.unwrap();
let mut config = crate::config::Config::default();
config.publish.epub_dir = dir.path().join("missing-epubs");
config.bookorbit.enabled = true;
config.bookorbit.opds_user = Some("reader".into());
config.bookorbit.opds_pass = Some("secret".into());
let app = crate::server::router(crate::server::AppState::new(db, config, None));
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
let issue = app
.oneshot(
Request::builder()
.uri(format!("/issues/{}", source.meta.date))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let issue = response_text(issue).await;
assert!(!issue.contains("Read in BookOrbit"));
assert!(!issue.contains(&format!("href=\"/issues/{}/read\"", source.meta.date)));
}
#[tokio::test]
async fn bookorbit_read_reports_upstream_connection_errors() {
let (_dir, db, source) = seeded_issue(true).await;
crate::web::users::add(&db, "reader", "correct horse battery", false)
.await
.unwrap();
let mut config = crate::config::Config::default();
config.bookorbit.enabled = true;
config.bookorbit.opds_user = Some("reader".into());
config.bookorbit.opds_pass = Some("secret".into());
config.bookorbit.api_url = "http://127.0.0.1:9".into();
let app = crate::server::router(crate::server::AppState::new(db, config, None));
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
let read = app
.oneshot(
Request::builder()
.uri(format!("/issues/{}/read", source.meta.date))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(read.status(), StatusCode::BAD_GATEWAY);
assert!(response_text(read).await.contains("BookOrbit"));
}
#[tokio::test]
async fn anonymous_bookorbit_read_redirects_to_login() {
let (_dir, db, source) = seeded_issue(true).await;
let mut config = crate::config::Config::default();
config.bookorbit.enabled = true;
config.bookorbit.opds_user = Some("reader".into());
config.bookorbit.opds_pass = Some("secret".into());
let app = crate::server::router(crate::server::AppState::new(db, config, None));
let read = app
.oneshot(
Request::builder()
.uri(format!("/issues/{}/read", source.meta.date))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(read.status(), StatusCode::FOUND);
assert_eq!(
read.headers().get(header::LOCATION).unwrap(),
format!("/login?next=%2Fissues%2F{}%2Fread", source.meta.date).as_str()
);
}
#[test] #[test]
fn file_sizes_are_human_readable() { fn file_sizes_are_human_readable() {
assert_eq!(format_file_size(42), "42 B"); assert_eq!(format_file_size(42), "42 B");
+15
View File
@@ -456,6 +456,12 @@ pub enum WebError {
Unauthenticated { next: String }, Unauthenticated { next: String },
#[error("bad request: {0}")] #[error("bad request: {0}")]
BadRequest(String), BadRequest(String),
/// BookOrbit has not indexed the requested issue yet.
#[error("service unavailable: {0}")]
ServiceUnavailable(String),
/// A BookOrbit OPDS request failed.
#[error("bad gateway: {0}")]
BadGateway(String),
#[error("request origin did not match this site")] #[error("request origin did not match this site")]
Csrf, Csrf,
#[error(transparent)] #[error(transparent)]
@@ -511,6 +517,14 @@ impl IntoResponse for WebError {
Self::BadRequest(ref message) => { Self::BadRequest(ref message) => {
(StatusCode::BAD_REQUEST, "Bad request", message.as_str()) (StatusCode::BAD_REQUEST, "Bad request", message.as_str())
} }
Self::ServiceUnavailable(ref message) => (
StatusCode::SERVICE_UNAVAILABLE,
"Not indexed yet",
message.as_str(),
),
Self::BadGateway(ref message) => {
(StatusCode::BAD_GATEWAY, "BookOrbit error", message.as_str())
}
Self::Db(ref error) => { Self::Db(ref error) => {
tracing::error!(%error, "web database request failed"); tracing::error!(%error, "web database request failed");
( (
@@ -660,6 +674,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
.route("/issues/{date}/articles/{article_id}", get(issue::article)) .route("/issues/{date}/articles/{article_id}", get(issue::article))
.route("/issues/{date}/world", get(issue::world)) .route("/issues/{date}/world", get(issue::world))
.route("/issues/{date}/behind", get(issue::behind)) .route("/issues/{date}/behind", get(issue::behind))
.route("/issues/{date}/read", get(issue::read))
.route_layer(login_required!( .route_layer(login_required!(
session::Backend, session::Backend,
login_url = "/login", login_url = "/login",
+1 -1
View File
@@ -3,7 +3,7 @@
<article class="reader-page mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0"> <article class="reader-page mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0">
<header class="mb-8 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ display_date }} · No. {{ issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ stats_line }}</p></header> <header class="mb-8 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ display_date }} · No. {{ issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ stats_line }}</p></header>
<section aria-labelledby="brief-heading" data-toc-entry="/issues/{{ date }}"><h1 id="brief-heading" class="reader-section-heading">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section> <section aria-labelledby="brief-heading" data-toc-entry="/issues/{{ date }}"><h1 id="brief-heading" class="reader-section-heading">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section>
{% if !downloads.is_empty() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% for download in downloads %}<a class="btn" href="{{ download.href }}">Download {{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %} {% if read_href.is_some() || !downloads.is_empty() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% match read_href %}{% when Some with (href) %}<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>{% when None %}{% endmatch %}{% for download in downloads %}<a class="btn" href="{{ download.href }}">Download {{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
<section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2> <section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2>
{% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %} {% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
</section> </section>