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:
@@ -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,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` ~531–544). 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:82–85`, 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 248–259 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 `&` 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.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE issues ADD COLUMN bookorbit_book_id INTEGER;
|
||||||
|
ALTER TABLE issues ADD COLUMN bookorbit_file_id INTEGER;
|
||||||
@@ -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 & 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&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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user