Compare commits
21
Commits
0f17480be7
...
main
No files matched your search
Generated
+965
File diff suppressed because it is too large.
Load diff
@@ -6,11 +6,13 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
ammonia = "4.1.4"
|
ammonia = "4.1.4"
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
|
arborium = { version = "2.18.2", default-features = false, features = ["lang-asm", "lang-awk", "lang-bash", "lang-c", "lang-c-sharp", "lang-clojure", "lang-cmake", "lang-commonlisp", "lang-cpp", "lang-css", "lang-d", "lang-dart", "lang-diff", "lang-dockerfile", "lang-elisp", "lang-elixir", "lang-elm", "lang-erlang", "lang-fish", "lang-fsharp", "lang-gleam", "lang-glsl", "lang-go", "lang-graphql", "lang-groovy", "lang-haskell", "lang-hcl", "lang-html", "lang-ini", "lang-java", "lang-javascript", "lang-jq", "lang-json", "lang-julia", "lang-kotlin", "lang-lean", "lang-lua", "lang-make", "lang-matlab", "lang-nix", "lang-objc", "lang-ocaml", "lang-odin", "lang-perl", "lang-php", "lang-powershell", "lang-prolog", "lang-proto", "lang-python", "lang-r", "lang-ruby", "lang-rust", "lang-scala", "lang-scheme", "lang-scss", "lang-solidity", "lang-sql", "lang-starlark", "lang-swift", "lang-toml", "lang-tsx", "lang-typescript", "lang-verilog", "lang-vhdl", "lang-vim", "lang-x86asm", "lang-xml", "lang-yaml", "lang-zig", "lang-zsh"] }
|
||||||
askama = "0.16.0"
|
askama = "0.16.0"
|
||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
axum-login = { git = "https://github.com/maxcountryman/axum-login.git", rev = "151c72d7a1b4646830f86b4332e6bd6e34d719a7", version = "0.18.0" }
|
axum-login = { git = "https://github.com/maxcountryman/axum-login.git", rev = "151c72d7a1b4646830f86b4332e6bd6e34d719a7", version = "0.18.0" }
|
||||||
base64 = "0.23.1"
|
base64 = "0.23.1"
|
||||||
|
betlang = "0.1.1"
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
clap = { version = "4.6.6", features = ["derive"] }
|
||||||
dom_smoothie = "0.18.0"
|
dom_smoothie = "0.18.0"
|
||||||
epub-builder = "0.8.3"
|
epub-builder = "0.8.3"
|
||||||
|
|||||||
+1
-1
@@ -157,7 +157,7 @@ affinity_full = 40
|
|||||||
slop_author_penalty = 0.75 # blend and utility × 0.25 for authors with an AI slop verdict
|
slop_author_penalty = 0.75 # blend and utility × 0.25 for authors with an AI slop verdict
|
||||||
semantic_min_words = 300
|
semantic_min_words = 300
|
||||||
exploration_slots = 5
|
exploration_slots = 5
|
||||||
embedding_retention_days = 120 # `features prune`: unrated, unpublished vectors
|
embedding_retention_days = 0 # `features prune`: unrated, unpublished vectors; 0 = keep forever
|
||||||
telemetry_retention_days = 180 # `features prune`: candidate_runs and article_assessments
|
telemetry_retention_days = 180 # `features prune`: candidate_runs and article_assessments
|
||||||
|
|
||||||
[curation.ranking.quotas]
|
[curation.ranking.quotas]
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Dashboard tables
|
||||||
|
|
||||||
|
How every data table on the dashboard is laid out, and the rules a new table
|
||||||
|
must follow so it never grows a horizontal scrollbar on a desktop screen.
|
||||||
|
Everything here is plain CSS in `src/web/tailwind.css` plus four cell classes;
|
||||||
|
there is no table library and no JavaScript involved.
|
||||||
|
|
||||||
|
## The problem this solves
|
||||||
|
|
||||||
|
A table used to be `min-width: max-content` inside a `.scroll-x` wrapper, and
|
||||||
|
most cells were `white-space: nowrap`. Any table whose natural width exceeded
|
||||||
|
the page column (80rem, ~1355px at the site's 110% scale) became a horizontal
|
||||||
|
scroller, even on a 2560px monitor, and the columns that mattered were hidden
|
||||||
|
behind the scrollbar. Wrapping columns were squeezed to their minimum while the
|
||||||
|
nowrap ones kept everything.
|
||||||
|
|
||||||
|
## The three rules
|
||||||
|
|
||||||
|
1. **Tables get the whole viewport, everything else keeps the page column.**
|
||||||
|
`.dashboard` is a CSS grid with two named column spans: `content` (at most
|
||||||
|
80rem minus the page gutters, exactly what the header uses) and `wide` (the
|
||||||
|
viewport minus the same gutters). Every direct child sits in `content`; a
|
||||||
|
direct-child `.scroll-x` sits in `wide`. The table inside a wide wrapper is
|
||||||
|
`width: auto` (shrink-to-fit) with `min-width` equal to the content column
|
||||||
|
and `max-width: 100%`, centred. A table that fits the page column therefore
|
||||||
|
looks exactly as before; a table that needs more grows, centred, up to the
|
||||||
|
viewport edge; the page itself never scrolls sideways.
|
||||||
|
|
||||||
|
2. **Columns wrap before the table scrolls.** The `min-width: max-content` rule
|
||||||
|
is gone. A table shrinks by wrapping its text columns down to their floors,
|
||||||
|
and only when the floors alone no longer fit (phones, mostly) does the
|
||||||
|
`.scroll-x` wrapper scroll. Headers always wrap.
|
||||||
|
|
||||||
|
3. **A cell declares what it holds, and nothing else decides its width.**
|
||||||
|
There are exactly four cell classes:
|
||||||
|
|
||||||
|
| class | use it for | behaviour |
|
||||||
|
| ------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| *(none)* | short prose, names, tokens, badges and badge lists | wraps at spaces (a `.badge` is nowrap on its own, so lists wrap between badges) |
|
||||||
|
| `num` | numbers, money, counts | right-aligned tabular figures; `td.num` never wraps, `th.num` may |
|
||||||
|
| `cell-tight` | timestamps, dates, ids, a single short token that must not split | `white-space: nowrap`. Nothing longer than ~20 characters |
|
||||||
|
| `cell-wrap` | titles, URLs, notes, messages, anything free-form | floor 10rem, ceiling 32rem, `overflow-wrap: anywhere` |
|
||||||
|
|
||||||
|
Anything long that must not wrap gets an inner block with a Tailwind clamp
|
||||||
|
(`<div class="line-clamp-2" title="…">`) inside a `cell-wrap` cell; the cell
|
||||||
|
ceiling bounds the column, the clamp bounds the row height. Do not put
|
||||||
|
`truncate`/`line-clamp-*` on a `td` itself (a table cell cannot be a
|
||||||
|
`-webkit-box`), and do not use `table-fixed` on a page-level table (fixed
|
||||||
|
layout needs a definite width and is silently ignored with `width: auto`).
|
||||||
|
|
||||||
|
## Checklist for a new table
|
||||||
|
|
||||||
|
- Wrap it in `<div class="scroll-x">` and make that wrapper a **direct child** of `<section class="dashboard">` if the
|
||||||
|
table has more than a handful of columns. Tables inside `.card`, `.cards` or
|
||||||
|
`details` stay inside their box and just wrap/scroll there.
|
||||||
|
- Let the page scroll. A table that is the page's feature (paginated lists,
|
||||||
|
anything that is the last thing on the page) shows every row at full height;
|
||||||
|
a scrolling island inside a scrolling page just fights the wheel. Add `tall`
|
||||||
|
(70vh cap, own scrollbar) only when the table is a side feature with real
|
||||||
|
content below it, as the profile page's version history is.
|
||||||
|
- Give every `td` one of the four classes above according to its content.
|
||||||
|
When in doubt, leave it unclassed: wrapping is the safe default.
|
||||||
|
- Never `cell-tight` a name, a title, a reason sentence or a free-text field.
|
||||||
|
- Check the page at 390, 1280 and 2560px. At 1440 and above the wrapper's
|
||||||
|
`scrollWidth` must equal its `clientWidth`; at 390 a wide table is allowed to
|
||||||
|
scroll. A table's floor is the sum of its nowrap columns plus 10rem per
|
||||||
|
`cell-wrap` column: the 13-column articles table bottoms out near 1270px, so
|
||||||
|
it still scrolls a little on a 1280px viewport. Tables inside a `.card`
|
||||||
|
cannot grow past the card (20rem minimum), so a card table with more than
|
||||||
|
four numeric columns (run page: provider usage) scrolls inside its card at
|
||||||
|
1280px; move such a table to page level if that matters.
|
||||||
|
|
||||||
|
## Measuring
|
||||||
|
|
||||||
|
`docs/plans/briefs/dashboard-tables/measure.mjs` logs in as the dev-seed admin,
|
||||||
|
visits every dashboard page at several widths and prints, per page, whether the
|
||||||
|
document overflows and which table wrappers scroll. Run it against a seeded
|
||||||
|
dev server (`cargo run --example seed_dev_db -- ./dev`) before and after a CSS
|
||||||
|
change; the "scrolling" count for widths ≥ 1280 should be zero.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Shared brief — dashboard tables (2026-09-20)
|
||||||
|
|
||||||
|
The operator sees horizontal scrollbars on most dashboard tables, even on an
|
||||||
|
ultrawide monitor, because every table is capped at the 80rem page column and
|
||||||
|
`min-width: max-content` forbids wrapping. The fix is a small, reusable table
|
||||||
|
framework described in **`docs/dashboard-tables.md` — read it first; it is the
|
||||||
|
spec.** Two tasks run in parallel in separate git worktrees:
|
||||||
|
|
||||||
|
- `01-css.md` — the CSS side (`src/web/tailwind.css`, rebuilt `app.css`).
|
||||||
|
- `02-templates.md` — the template audit (every `td` gets the right cell class).
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- Work only in the worktree named in your brief. **Never touch the main checkout**
|
||||||
|
at `/home/thallada/workspace/the-daily-epub`. **Do not `git commit`** (the sandbox
|
||||||
|
cannot write `.git/worktrees`); leave the work in the tree, the orchestrator
|
||||||
|
commits.
|
||||||
|
- Styling lives in **`src/web/tailwind.css`** (Tailwind v4, `@apply` inside
|
||||||
|
`@layer components`, semantic colour tokens). After every CSS change run
|
||||||
|
`npm run css` (writes `src/web/static/app.css`, which the binary embeds with
|
||||||
|
`include_str!`). `npm run css:check` must print no diff at the end.
|
||||||
|
- CSP forbids inline `<style>`, `<script>` and `style=""` attributes. No new JS,
|
||||||
|
no new dependencies, no table library: this is plain CSS + four cell classes.
|
||||||
|
- Keep every hook `app.js` relies on: `table[data-filter]`, `.table-filter`,
|
||||||
|
`input[data-table-filter]`, `.scroll-x` (the filter script does
|
||||||
|
`table.closest(".scroll-x")`), `form[data-confirm]`, `details[id]`, `[data-refresh]`.
|
||||||
|
- Keep `cargo fmt`, `cargo clippy --all-targets -- -D warnings` and the tests
|
||||||
|
green (`cargo test --lib web::` while iterating, one full `cargo test` at the
|
||||||
|
end). **Sandbox note:** inside the Codex sandbox `bind()` on 127.0.0.1 is
|
||||||
|
forbidden; ignore every test that fails with `PermissionDenied`/bind errors
|
||||||
|
(about 17 pre-existing ones). Do not "fix" them.
|
||||||
|
- 4 cores / 7 GB shared with another agent building Rust at the same time: one
|
||||||
|
cargo process at a time, be patient.
|
||||||
|
- KISS / YAGNI: no config knobs, no per-table width tables, no JS resizing, no
|
||||||
|
responsive card-ification of rows. Where the brief and the code disagree,
|
||||||
|
follow the code and note the deviation in your handoff.
|
||||||
|
- Finish by writing `docs/plans/briefs/dashboard-tables/handoff-<task>.md`:
|
||||||
|
what changed (file by file), deviations, what you verified and how, anything
|
||||||
|
left open.
|
||||||
|
|
||||||
|
## Previewing (optional — the sandbox usually cannot bind a port)
|
||||||
|
|
||||||
|
Seed a throwaway database with `cargo run --example seed_dev_db -- ./dev`
|
||||||
|
(git-ignored) and serve it with
|
||||||
|
`DAILY_EPUB_SERVER__HMAC_SECRET=$(head -c 48 /dev/urandom | base64) DAILY_EPUB_SERVER__BIND=127.0.0.1:<port> cargo run -- --config ./dev/config.toml serve`.
|
||||||
|
`measure.mjs` in this directory (needs `playwright` on the module path; a copy
|
||||||
|
lives at `~/.npm/_npx/705bc6b22212b352/node_modules`) prints per page and width
|
||||||
|
whether the document overflows and which table wrappers scroll. If you cannot
|
||||||
|
bind, rely on the router tests and say so; the orchestrator screenshots every
|
||||||
|
page at 390/768/1280/1920/2560 after merging.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Task 01 — table framework CSS
|
||||||
|
|
||||||
|
Worktree: `/home/thallada/workspace/the-daily-epub-tables-css` (branch `tables-css`).
|
||||||
|
Read `00-shared.md` and `docs/dashboard-tables.md` first. Only `src/web/tailwind.css`
|
||||||
|
and the rebuilt `src/web/static/app.css` change in this task (plus your handoff).
|
||||||
|
The template audit happens in a sibling worktree; do not edit templates.
|
||||||
|
|
||||||
|
## 1. `.dashboard` becomes a content grid
|
||||||
|
|
||||||
|
Replace `.dashboard { @apply mx-auto my-8 max-w-7xl px-4 pb-6 … sm:px-6; }` with a
|
||||||
|
grid that keeps the page column pixel-identical to the header
|
||||||
|
(`mx-auto max-w-7xl px-4 sm:px-6` = content width `80rem - 2 * gutter`, centred):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.dashboard {
|
||||||
|
--gutter: 1rem;
|
||||||
|
@apply my-8 grid pb-6 font-sans text-base leading-normal;
|
||||||
|
grid-template-columns:
|
||||||
|
[full-start] var(--gutter)
|
||||||
|
[wide-start] 1fr
|
||||||
|
[content-start] minmax(0, calc(80rem - 2 * var(--gutter)))
|
||||||
|
[content-end] 1fr
|
||||||
|
[wide-end] var(--gutter)
|
||||||
|
[full-end];
|
||||||
|
}
|
||||||
|
@media (width >= 40rem) { .dashboard { --gutter: 1.5rem; } } /* Tailwind `sm` */
|
||||||
|
.dashboard > * { grid-column: content; min-width: 0; }
|
||||||
|
.dashboard > .scroll-x { grid-column: wide; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `.dashboard > * + * { mt-4 }` and every other `.dashboard …` rule as they
|
||||||
|
are (margins on grid items work). Verify in the templates that nothing relied
|
||||||
|
on the old padding: `.save-bar` uses `-mx-4 sm:-mx-6` to bleed to the edges;
|
||||||
|
under the grid it still overflows its `content` area into the gutter columns
|
||||||
|
by the same amount, so leave it unless you see a problem. The `sm` breakpoint
|
||||||
|
is `40rem` in Tailwind v4 and `@media` rem is always 16px-based, so the query
|
||||||
|
above matches `sm:` exactly.
|
||||||
|
|
||||||
|
Prototype measured in Chromium and Firefox at 390/1280/2560: `h1` left edge and
|
||||||
|
width identical to today, no document overflow, table centred and wider than
|
||||||
|
the column only when its content needs it.
|
||||||
|
|
||||||
|
## 2. Tables shrink by wrapping; page-level tables may grow past the column
|
||||||
|
|
||||||
|
- Delete `.scroll-x > table { min-width:max-content; }`.
|
||||||
|
- Add, for the wide wrapper only:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.dashboard > .scroll-x > table {
|
||||||
|
width: auto; /* shrink-to-fit */
|
||||||
|
min-width: min(100%, calc(80rem - 2 * var(--gutter)));
|
||||||
|
max-width: 100%;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A table narrower than the page column fills the column as today; a wider one
|
||||||
|
grows, centred, to the viewport edge; the wrapper scrolls only past that.
|
||||||
|
Tables that are not direct children (cards, disclosures, signal tables inside
|
||||||
|
cells) keep `w-full` from the base rule and simply wrap, then scroll.
|
||||||
|
|
||||||
|
## 3. Cell vocabulary (exactly four classes, see the doc's table)
|
||||||
|
|
||||||
|
- `.num` → `@apply text-right tabular-nums;` and `td.num { @apply whitespace-nowrap; }`.
|
||||||
|
Header cells with `.num` may wrap ("cache write", "matched articles").
|
||||||
|
- `.cell-tight` unchanged (`whitespace-nowrap`).
|
||||||
|
- `.cell-wrap` → `@apply min-w-[10rem] max-w-[32rem] whitespace-normal; overflow-wrap:anywhere;`
|
||||||
|
(ceiling raised from 24rem so titles get a single line when the screen has
|
||||||
|
room; `max-width` on a cell is honoured by Chromium and Firefox for the
|
||||||
|
column's max-content contribution — measured).
|
||||||
|
- `th` keeps wrapping (no nowrap); keep the sticky `thead`.
|
||||||
|
- `td > pre.preview { max-w-md }` and `.rating-cell` rules stay.
|
||||||
|
|
||||||
|
## 4. Small things to keep honest
|
||||||
|
|
||||||
|
- `.prose-body table` (reader pages) keeps its own rules; do not touch.
|
||||||
|
- Put a short comment above the table rules pointing at `docs/dashboard-tables.md`.
|
||||||
|
- `npm run css`, then `cargo build` (the binary embeds app.css) and
|
||||||
|
`cargo test --lib web::`. Router tests assert on markup, not on these rules,
|
||||||
|
so nothing should break; if something does, say what and why in the handoff.
|
||||||
|
- If you can run the dev server + `measure.mjs`, do it and paste the ≥1280 lines
|
||||||
|
for `/dashboard/articles`, `/dashboard/runs` and `/dashboard/runs/1` into the
|
||||||
|
handoff. With unchanged templates some tables will still scroll at 1280 —
|
||||||
|
that is expected until the template audit lands — but at 2560 the articles
|
||||||
|
and candidates tables must fit without scrolling.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Task 02 — template audit: every cell says what it holds
|
||||||
|
|
||||||
|
Worktree: `/home/thallada/workspace/the-daily-epub-tables-tpl` (branch `tables-tpl`).
|
||||||
|
Read `00-shared.md` and `docs/dashboard-tables.md` first. Only files under
|
||||||
|
`src/web/templates/` change in this task (plus your handoff). The CSS lands in a
|
||||||
|
sibling worktree; assume the four cell classes behave exactly as the doc's
|
||||||
|
table says (`cell-wrap` floor 10rem / ceiling 32rem with `overflow-wrap:anywhere`,
|
||||||
|
`cell-tight` nowrap, `td.num` nowrap right-aligned, unclassed cells wrap at
|
||||||
|
spaces, `th` always wraps, page-level tables grow past the page column when
|
||||||
|
their content needs it).
|
||||||
|
|
||||||
|
Go through every `<table>` in `src/web/templates/dashboard/*.html`,
|
||||||
|
`src/web/templates/_candidate_row.html` and `src/web/templates/_signals_table.html`
|
||||||
|
and apply these rules to each `td`:
|
||||||
|
|
||||||
|
1. **`cell-tight` only on timestamps, dates, ids and single tokens ≤ ~20
|
||||||
|
characters** (`first_seen`, `when`, `started`, `finished`, `requested`,
|
||||||
|
`created`, `last_login`, `saved_at`, `changed_at`, `event_at`, `issue_date`,
|
||||||
|
`published`, `run.date`, `run.funnel` "310 → 200 → …", `age → decay`,
|
||||||
|
`#id`). **Remove it** from feed names/titles (`article.feed`, `candidate.feed`,
|
||||||
|
`miss.feed`, `row.feed_credits`, nearest-article `feed`), reasons
|
||||||
|
(`article.reason`, `row.reason`, `candidate.reason`), "admitted by" cells,
|
||||||
|
usernames / `requested_by` / `saved_by`, roles, sources, kinds, job names,
|
||||||
|
and from any cell that combines a badge with more text (stage + run date).
|
||||||
|
A `.badge` is already `whitespace-nowrap` on its own, so a badge-only cell
|
||||||
|
needs no class.
|
||||||
|
2. **`cell-wrap` on every free-form column**: titles, URLs, notes, messages,
|
||||||
|
"reason or comment", facet values, badge lists (interests), config diff
|
||||||
|
before/after, anything a user typed. Keep it where it already is.
|
||||||
|
3. **`num` on numbers only** (it already is; just make sure no text column
|
||||||
|
uses it because it wanted right alignment — `#id` cells may keep `num`).
|
||||||
|
4. **Long text that should not stretch a row**: if a column can hold hundreds of
|
||||||
|
characters (job `message`, rating-import `message`, `note`), keep
|
||||||
|
`cell-wrap` and wrap the text in `<div class="line-clamp-3" title="{{ … }}">`
|
||||||
|
**only** if the full text is reachable somewhere else on the page or via the
|
||||||
|
`title`; otherwise let it wrap. Never put `truncate`/`line-clamp-*` on a `td`.
|
||||||
|
5. **`dashboard/feeds.html`**: drop `table-fixed` (fixed layout is ignored once
|
||||||
|
the page-level table is `width:auto`). Keep the `w-*` hints on the `th`s
|
||||||
|
(they act as minimums under auto layout), keep the `line-clamp-*`/`truncate`
|
||||||
|
inner elements — they already sit on block children, which is the pattern —
|
||||||
|
and put `cell-wrap` on the Feed and Why cells if it is not there. The
|
||||||
|
actions cell keeps its forms; give it `cell-tight` only if the buttons wrap
|
||||||
|
badly, otherwise leave it unclassed.
|
||||||
|
6. **`dashboard/run.html` Feeds card** (`<table class="table-fixed">` inside
|
||||||
|
`.card`): this table is `w-full` inside a card, so `table-fixed` still works
|
||||||
|
there; leave it, but it is the only allowed `table-fixed`.
|
||||||
|
7. **Header cells**: remove nothing, but make sure no `th` has `whitespace-nowrap`
|
||||||
|
or `cell-tight`; long headers such as
|
||||||
|
"considered → eligible → triaged → assessed → shortlisted → selected" may wrap.
|
||||||
|
8. **Wrappers**: every page-level table (more than a handful of columns:
|
||||||
|
runs, candidates, articles, ratings current/events, interests, feeds, users,
|
||||||
|
jobs, stats runs/costs, settings history, profile versions, near misses,
|
||||||
|
funnel) must sit in a `<div class="scroll-x …">` that is a **direct child**
|
||||||
|
of `<section class="dashboard …">` — check that no such wrapper is nested in
|
||||||
|
a stray `<div>`; tables inside `.card`/`.cards`/`details` stay where they are.
|
||||||
|
Keep `tall` where it is today.
|
||||||
|
|
||||||
|
Do not change the columns themselves, their order, the text, or any
|
||||||
|
`data-*`/`id`/`name`/`action`/`aria-*` attribute. Do not touch reader/public
|
||||||
|
templates (`issue_*.html`, `article.html`, `world.html`, `behind.html`, `_toc.html`).
|
||||||
|
|
||||||
|
Verify: `cargo test --lib web::` (router tests assert on text and some class
|
||||||
|
names; if one breaks only because a class moved, update the test and say so),
|
||||||
|
then `cargo fmt`/`clippy`. List in the handoff, per template, which cells lost
|
||||||
|
`cell-tight`, which gained `cell-wrap`, and any clamp you added.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Dashboard tables CSS handoff
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
- `src/web/tailwind.css`
|
||||||
|
- Disabled Tailwind v4 automatic repository scanning with
|
||||||
|
`@import "tailwindcss" source(none);`; retained the explicit template and
|
||||||
|
`app.js` sources.
|
||||||
|
- Replaced the padded/max-width dashboard container with the specified named
|
||||||
|
`content`/`wide` grid and 1rem/1.5rem gutter variable.
|
||||||
|
- Let direct-child `.scroll-x` wrappers occupy the wide track and made their
|
||||||
|
tables shrink-to-fit between the content-column floor and wrapper ceiling.
|
||||||
|
- Removed the global `.scroll-x > table { min-width:max-content; }` rule.
|
||||||
|
- Kept `.num` alignment/tabular figures on headers and cells, while limiting
|
||||||
|
nowrap to `td.num`.
|
||||||
|
- Raised `.cell-wrap`'s maximum width from 24rem to 32rem.
|
||||||
|
- Added a pointer from the table rules to `docs/dashboard-tables.md`.
|
||||||
|
- `src/web/static/app.css`
|
||||||
|
- Rebuilt from the Tailwind source and checked for reproducibility.
|
||||||
|
|
||||||
|
No templates, JavaScript, reader table rules, preview rules, or rating-cell
|
||||||
|
rules changed.
|
||||||
|
|
||||||
|
## Generated CSS diff audit
|
||||||
|
|
||||||
|
Compared committed and rebuilt CSS one closing brace per line with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
diff -U0 \
|
||||||
|
<(git show HEAD:src/web/static/app.css | tr '}' '\n') \
|
||||||
|
<(tr '}' '\n' < src/web/static/app.css)
|
||||||
|
```
|
||||||
|
|
||||||
|
The 177-line diff contained 111 removed and 12 added content lines (excluding
|
||||||
|
the two diff file headers). The component-rule changes were exactly the table
|
||||||
|
framework changes above: remove `.scroll-x > table`, replace `.dashboard`, add
|
||||||
|
the three direct-child dashboard selectors, split `td.num` nowrap from `.num`,
|
||||||
|
and change `.cell-wrap` from 24rem to 32rem. The remainder was removal of
|
||||||
|
utilities, unused theme symbols, and supporting custom properties that had
|
||||||
|
previously been emitted by repository-wide automatic source discovery. There
|
||||||
|
were no unrelated added selectors.
|
||||||
|
|
||||||
|
The rebuilt file shrank from 77,721 to 70,697 bytes. The documentation-only
|
||||||
|
utility selectors `.line-clamp-3`, `.max-w-[32rem]`, `.min-w-[10rem]`, and
|
||||||
|
`.pb-6` are absent. The semantic `.cell-wrap` component still contains
|
||||||
|
`min-width:10rem` and `max-width:32rem`, and `.dashboard` still contains its
|
||||||
|
requested `padding-bottom` declaration.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npm run css` — passed.
|
||||||
|
- `npm run css:check` — passed with no diff.
|
||||||
|
- `cargo build` — passed after the final CSS build.
|
||||||
|
- `cargo test --lib web::` — 135 passed; the two failures were
|
||||||
|
`web::dashboard::feeds::tests::a_duplicate_subscription_still_decides_the_row`
|
||||||
|
and `web::dashboard::feeds::tests::add_subscribes_and_records_the_miniflux_feed_id`.
|
||||||
|
Both failed at the test bind setup with the documented
|
||||||
|
`PermissionDenied` / `Operation not permitted` sandbox error.
|
||||||
|
- `cargo fmt` — passed without changes.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings` — passed.
|
||||||
|
- `git diff --check` — passed.
|
||||||
|
|
||||||
|
An extra full `cargo test` required by the shared brief was attempted, but the
|
||||||
|
filesystem ran out of space while creating another test archive before tests
|
||||||
|
started. I removed only this package's generated artifacts with
|
||||||
|
`cargo clean -p daily-epub` (2.0 GiB), then reran the complete user-requested
|
||||||
|
command sequence above successfully. The full suite remains unverified; the
|
||||||
|
focused web suite is verified subject only to the two allowed bind failures.
|
||||||
|
|
||||||
|
## Deviations and open items
|
||||||
|
|
||||||
|
There are no implementation deviations. I did not run the browser measurement
|
||||||
|
script because the sandbox cannot bind a preview server, as independently
|
||||||
|
confirmed by the focused test failures. Visual viewport measurement remains for
|
||||||
|
the post-merge environment described in the shared brief.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Dashboard tables template handoff
|
||||||
|
|
||||||
|
Implemented `02-templates.md` against `00-shared.md` and
|
||||||
|
`docs/dashboard-tables.md`. No columns, content, hooks, or attributes were
|
||||||
|
changed. The existing page-level `.scroll-x` wrappers already had the required
|
||||||
|
direct-child topology, so none moved.
|
||||||
|
|
||||||
|
## Cell audit by template
|
||||||
|
|
||||||
|
| Template | Cells that lost `cell-tight` | Cells that gained `cell-wrap` | Clamp added |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `_candidate_row.html` | Feed, stage, reason, admitted by, flags | Flags (badge list) | None |
|
||||||
|
| `_signals_table.html` | None | None | None |
|
||||||
|
| `dashboard/article.html` | Run history: stage, reason, admitted by; nearest articles: feed, rating; rating events: label, kind, source, user | Top-interests interest name | Rating-event note: inner `line-clamp-3` with the full note in `title` |
|
||||||
|
| `dashboard/articles.html` | Feed, last stage (badge plus run date), reason, rating | None | None |
|
||||||
|
| `dashboard/feeds.html` | None | None (Feed and Why already had it) | None (existing Feed/Why inner clamps retained) |
|
||||||
|
| `dashboard/interests.html` | Category | None | None |
|
||||||
|
| `dashboard/jobs.html` | Job name, requested by, status | None (message already had it) | Message: inner `line-clamp-3` with the full message in `title` |
|
||||||
|
| `dashboard/profile.html` | Saved by, restore action | Preview | None |
|
||||||
|
| `dashboard/ratings.html` | Imports: verdict, status; events: verdict, source, by; current: when/by, feed credit, used last run | Current feed credit | Import message, event note, and current note: inner `line-clamp-3` with full text in `title` |
|
||||||
|
| `dashboard/run.html` | Funnel stage; near misses: feed, stage | None | None |
|
||||||
|
| `dashboard/runs.html` | Status (badge plus dry-run text) | None | None |
|
||||||
|
| `dashboard/settings_history.html` | Who | None | None |
|
||||||
|
| `dashboard/stats.html` | Runs status | None | None |
|
||||||
|
| `dashboard/users.html` | None | None | None |
|
||||||
|
|
||||||
|
`dashboard/feeds.html` also lost page-level `table-fixed`; its existing header
|
||||||
|
width hints and inner Feed/Why clamps remain. The card-local `table-fixed` feeds
|
||||||
|
table in `dashboard/run.html` remains as specified.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
None. Templates whose cells were already compliant were left unchanged.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `cargo test --lib web::`: 135 passed; two Miniflux mock-server tests failed
|
||||||
|
only because sandbox `bind()` returned `PermissionDenied` at
|
||||||
|
`src/web/dashboard/feeds.rs:497`, as permitted by the brief.
|
||||||
|
- `cargo fmt`: passed.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings`: passed.
|
||||||
|
- `git diff --check`: passed.
|
||||||
|
|
||||||
|
The first clippy attempt exhausted the filesystem while writing generated Rust
|
||||||
|
metadata. Removing the disposable `target/debug/incremental` cache freed enough
|
||||||
|
space; the exact clippy command then completed successfully.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// usage: node measure.mjs <port> <outdir> [widths]
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import fs from "node:fs";
|
||||||
|
const [port, outdir, widthsArg] = process.argv.slice(2);
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const widths = (widthsArg || "390,768,1280,1920,2560").split(",").map(Number);
|
||||||
|
const pages = ["/dashboard","/dashboard/runs","/dashboard/runs/1","/dashboard/runs/2","/dashboard/articles","/dashboard/articles/1","/dashboard/ratings","/dashboard/ratings?tab=events","/dashboard/interests","/dashboard/feeds","/dashboard/feeds?status=added","/dashboard/profile","/dashboard/stats","/dashboard/jobs","/dashboard/settings","/dashboard/settings/history","/dashboard/users","/","/issues","/account"];
|
||||||
|
fs.mkdirSync(outdir, { recursive: true });
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto(`${base}/login`);
|
||||||
|
await page.fill('input[name="username"]', "admin");
|
||||||
|
await page.fill('input[name="password"]', "adminpassword123");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
const report = [];
|
||||||
|
for (const w of widths) {
|
||||||
|
await page.setViewportSize({ width: w, height: 1000 });
|
||||||
|
for (const p of pages) {
|
||||||
|
const resp = await page.goto(`${base}${p}`, { waitUntil: "networkidle" });
|
||||||
|
const status = resp?.status();
|
||||||
|
const info = await page.evaluate(() => {
|
||||||
|
const de = document.documentElement;
|
||||||
|
const container = document.querySelector(".dashboard, main > section, main > div, main");
|
||||||
|
const tables = [...document.querySelectorAll("table")].map((t) => {
|
||||||
|
const host = t.closest(".scroll-x") || t.parentElement;
|
||||||
|
const r = t.getBoundingClientRect();
|
||||||
|
return { cls: t.className, ths: t.querySelectorAll("thead th").length, rows: t.querySelectorAll("tbody tr").length, tableW: Math.round(r.width), hostW: Math.round(host.getBoundingClientRect().width), hostScroll: host.scrollWidth, hostClient: host.clientWidth, overflow: host.scrollWidth > host.clientWidth + 1 };
|
||||||
|
});
|
||||||
|
const culprits = [...document.querySelectorAll("body *")].filter((el) => el.scrollWidth > el.clientWidth + 1 && getComputedStyle(el).overflowX === "visible" && !el.closest("table")).slice(0, 5).map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(" ").slice(0,2).join(".")} ${el.scrollWidth}>${el.clientWidth}`);
|
||||||
|
return { pageOverflow: de.scrollWidth > de.clientWidth, docW: de.clientWidth, containerW: container ? Math.round(container.getBoundingClientRect().width) : null, tables, culprits };
|
||||||
|
});
|
||||||
|
const name = p.replace(/[\/?=]/g, "_").replace(/^_/, "") || "root";
|
||||||
|
if (w === 1280 || w === 2560 || w === 390) await page.screenshot({ path: `${outdir}/${w}-${name}.png`, fullPage: true });
|
||||||
|
report.push({ w, p, status, ...info });
|
||||||
|
const bad = info.tables.filter((t) => t.overflow);
|
||||||
|
console.log(`${w} ${p} status=${status} page=${info.pageOverflow ? "OVERFLOW" : "ok"} container=${info.containerW} tables=${info.tables.length} scrolling=${bad.length}${bad.length ? " [" + bad.map((t) => `${t.cls || "-"}:${t.hostScroll}>${t.hostClient}`).join(", ") + "]" : ""}${info.culprits.length ? " culprits=" + info.culprits.join(" | ") : ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.writeFileSync(`${outdir}/report.json`, JSON.stringify(report, null, 1));
|
||||||
|
await browser.close();
|
||||||
+19
-3
@@ -131,9 +131,25 @@ fn body_html(title: &str, words: i64) -> String {
|
|||||||
html.push_str("<blockquote><p>A pull quote that the author would rather you remembered than the rest.</p></blockquote>");
|
html.push_str("<blockquote><p>A pull quote that the author would rather you remembered than the rest.</p></blockquote>");
|
||||||
}
|
}
|
||||||
if index == 7 {
|
if index == 7 {
|
||||||
html.push_str(
|
// One block the source marked as Rust, one it left for detection.
|
||||||
"<pre><code>fn main() {\n println!(\"hello, reader\");\n}</code></pre>",
|
html.push_str(concat!(
|
||||||
);
|
"<pre data-lang=\"rust\"><code>/// Greets whoever is reading.\n",
|
||||||
|
"fn main() {\n",
|
||||||
|
" let reader: &str = \"reader\";\n",
|
||||||
|
" for n in 0..3 {\n",
|
||||||
|
" println!(\"hello, {reader} #{n}\"); // three times\n",
|
||||||
|
" }\n",
|
||||||
|
"}</code></pre>",
|
||||||
|
"<pre><code>import json\n\n",
|
||||||
|
"def load(path: str) -> dict:\n",
|
||||||
|
" \"\"\"Read a JSON config, or an empty one.\"\"\"\n",
|
||||||
|
" try:\n",
|
||||||
|
" with open(path) as f:\n",
|
||||||
|
" return json.load(f)\n",
|
||||||
|
" except FileNotFoundError:\n",
|
||||||
|
" return {} # first run\n",
|
||||||
|
"</code></pre>",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
html.push_str("<p>Paragraph ");
|
html.push_str("<p>Paragraph ");
|
||||||
html.push_str(&(index + 1).to_string());
|
html.push_str(&(index + 1).to_string());
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
//! Preview an article the way the Xteink X4 will show it.
|
||||||
|
//!
|
||||||
|
//! Browsers are a poor stand-in for the X4: its pages are rendered ahead of
|
||||||
|
//! time by the XTC converter's CREngine, which has its own defaults (`code` is a
|
||||||
|
//! block, `pre` has no margin) and only four grey levels. This builds both
|
||||||
|
//! EPUB editions around one article, runs the real converter on the X4 one and
|
||||||
|
//! decodes every page to `page-NNN.png`.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run --example x4_preview -- <article URL | HTML file> <out dir>
|
||||||
|
//! cargo run --example x4_preview -- --convert <x4.epub> <out dir>
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! A URL goes through the real extraction path; an HTML file is used as the
|
||||||
|
//! stored body as-is. `--convert` skips the build, for iterating on a
|
||||||
|
//! stylesheet inside an unzipped EPUB. Needs the converter at
|
||||||
|
//! `/opt/epub-to-xtc-converter` and runs it with `xtc-settings.example.json`.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use daily_epub::config::Config;
|
||||||
|
use daily_epub::epub;
|
||||||
|
use daily_epub::extract::Extractor;
|
||||||
|
use daily_epub::types::Edition;
|
||||||
|
|
||||||
|
const CONVERTER: &str = "/opt/epub-to-xtc-converter/cli/index.js";
|
||||||
|
const SETTINGS: &str = "xtc-settings.example.json";
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
match args.as_slice() {
|
||||||
|
[flag, epub, out] if flag == "--convert" => {
|
||||||
|
let pages = convert(Path::new(epub), Path::new(out))?;
|
||||||
|
println!("{pages} pages in {out}");
|
||||||
|
}
|
||||||
|
[source, out] => {
|
||||||
|
let out = PathBuf::from(out);
|
||||||
|
std::fs::create_dir_all(&out)?;
|
||||||
|
let html = if source.starts_with("http") {
|
||||||
|
let extractor = Extractor::new(reqwest::Client::new(), vec![]);
|
||||||
|
let page = extractor.fetch_readable(source).await?;
|
||||||
|
extractor.finish_readable(source, &page).content_html
|
||||||
|
} else {
|
||||||
|
std::fs::read_to_string(source)?
|
||||||
|
};
|
||||||
|
std::fs::write(out.join("stored.html"), &html)?;
|
||||||
|
|
||||||
|
let mut issue = epub::fixtures::issue();
|
||||||
|
issue.lineup.picks.truncate(1);
|
||||||
|
issue.lineup.picks[0].article.content_html = html;
|
||||||
|
let config = Config::default();
|
||||||
|
for edition in [Edition::Standard, Edition::X4] {
|
||||||
|
let artifact =
|
||||||
|
epub::build_edition_with_images(&issue, edition, &config, &out, &[])?;
|
||||||
|
println!("{edition:?}: {}", artifact.path.display());
|
||||||
|
if edition == Edition::X4 {
|
||||||
|
let pages = convert(&artifact.path, &out)?;
|
||||||
|
println!("{pages} X4 pages in {}", out.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => anyhow::bail!(
|
||||||
|
"usage: x4_preview <URL | HTML file> <out dir> | --convert <epub> <out dir>"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the converter on `epub` and decode its pages into `out`.
|
||||||
|
fn convert(epub: &Path, out: &Path) -> anyhow::Result<usize> {
|
||||||
|
std::fs::create_dir_all(out)?;
|
||||||
|
let xtch = out.join("x4.xtch");
|
||||||
|
let status = Command::new("node")
|
||||||
|
.args([CONVERTER, "convert"])
|
||||||
|
.arg(epub)
|
||||||
|
.arg("-o")
|
||||||
|
.arg(&xtch)
|
||||||
|
.args(["-f", "xtch", "-c", SETTINGS])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.status()?;
|
||||||
|
anyhow::ensure!(status.success(), "converter failed: {status}");
|
||||||
|
decode_xtch(&xtch, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u16_at(bytes: &[u8], at: usize) -> usize {
|
||||||
|
u16::from_le_bytes([bytes[at], bytes[at + 1]]) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u64_at(bytes: &[u8], at: usize) -> usize {
|
||||||
|
u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode every page of an XTCH container to a PNG.
|
||||||
|
///
|
||||||
|
/// Layout per the converter's `docs/xtc-format-spec.md`: the page index is at
|
||||||
|
/// the offset in header bytes 0x18..0x20, 16 bytes per page (offset, size,
|
||||||
|
/// width, height). Each page is a 22-byte header and two bit planes stored
|
||||||
|
/// column by column, right to left, 8 vertical pixels per byte. The plane
|
||||||
|
/// order follows the encoder (`cli/encoder.js`), not the spec's table: the
|
||||||
|
/// first plane holds the level's low bit, and 0b01 is dark grey.
|
||||||
|
fn decode_xtch(path: &Path, out: &Path) -> anyhow::Result<usize> {
|
||||||
|
let bytes = std::fs::read(path)?;
|
||||||
|
let pages = u16_at(&bytes, 0x06);
|
||||||
|
let index = u64_at(&bytes, 0x18);
|
||||||
|
for page in 0..pages {
|
||||||
|
let entry = index + page * 16;
|
||||||
|
let (offset, width, height) = (
|
||||||
|
u64_at(&bytes, entry),
|
||||||
|
u16_at(&bytes, entry + 12),
|
||||||
|
u16_at(&bytes, entry + 14),
|
||||||
|
);
|
||||||
|
let plane = (width * height).div_ceil(8);
|
||||||
|
let column = height.div_ceil(8);
|
||||||
|
let data = &bytes[offset + 22..];
|
||||||
|
let mut image = image::GrayImage::new(width as u32, height as u32);
|
||||||
|
for x in 0..width {
|
||||||
|
for y in 0..height {
|
||||||
|
let byte = (width - 1 - x) * column + y / 8;
|
||||||
|
let bit = 7 - (y % 8);
|
||||||
|
let low = (data[byte] >> bit) & 1;
|
||||||
|
let high = (data[plane + byte] >> bit) & 1;
|
||||||
|
let shade = [255, 85, 170, 0][usize::from(high << 1 | low)];
|
||||||
|
image.put_pixel(x as u32, y as u32, image::Luma([shade]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
image.save(out.join(format!("page-{page:03}.png")))?;
|
||||||
|
}
|
||||||
|
Ok(pages)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Related articles under each web issue article: the nearest embedded articles
|
||||||
|
-- across the whole archive, computed when the issue is published (and by
|
||||||
|
-- `features related` for older issues). Stored rather than computed per page
|
||||||
|
-- view because finding them is a scan of every cached embedding.
|
||||||
|
CREATE TABLE issue_related (
|
||||||
|
issue_date TEXT NOT NULL REFERENCES issues(date) ON DELETE CASCADE,
|
||||||
|
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
position INTEGER NOT NULL, -- 0 = closest
|
||||||
|
related_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
cosine REAL NOT NULL,
|
||||||
|
PRIMARY KEY (issue_date, article_id, position)
|
||||||
|
);
|
||||||
+4
-2
@@ -490,6 +490,8 @@ pub struct RankingConfig {
|
|||||||
pub slop_author_penalty: f64,
|
pub slop_author_penalty: f64,
|
||||||
pub semantic_min_words: i64,
|
pub semantic_min_words: i64,
|
||||||
pub exploration_slots: usize,
|
pub exploration_slots: usize,
|
||||||
|
/// Days before `features prune` drops unrated, unpublished vectors; 0 keeps
|
||||||
|
/// them forever (the default, so related articles span the whole archive).
|
||||||
pub embedding_retention_days: i64,
|
pub embedding_retention_days: i64,
|
||||||
pub telemetry_retention_days: i64,
|
pub telemetry_retention_days: i64,
|
||||||
pub quotas: RankingQuotas,
|
pub quotas: RankingQuotas,
|
||||||
@@ -517,7 +519,7 @@ impl Default for RankingConfig {
|
|||||||
slop_author_penalty: 0.75,
|
slop_author_penalty: 0.75,
|
||||||
semantic_min_words: 300,
|
semantic_min_words: 300,
|
||||||
exploration_slots: 5,
|
exploration_slots: 5,
|
||||||
embedding_retention_days: 120,
|
embedding_retention_days: 0,
|
||||||
telemetry_retention_days: 180,
|
telemetry_retention_days: 180,
|
||||||
quotas: RankingQuotas::default(),
|
quotas: RankingQuotas::default(),
|
||||||
weights: RankingWeights::default(),
|
weights: RankingWeights::default(),
|
||||||
@@ -2104,7 +2106,7 @@ mod tests {
|
|||||||
assert_eq!(ranking.weights.utility.knn, 0.10);
|
assert_eq!(ranking.weights.utility.knn, 0.10);
|
||||||
assert_eq!(ranking.weights.utility.affinity, 0.05);
|
assert_eq!(ranking.weights.utility.affinity, 0.05);
|
||||||
assert_eq!(ranking.diversity.per_cluster_cap, 2);
|
assert_eq!(ranking.diversity.per_cluster_cap, 2);
|
||||||
assert_eq!(ranking.embedding_retention_days, 120);
|
assert_eq!(ranking.embedding_retention_days, 0);
|
||||||
assert_eq!(ranking.telemetry_retention_days, 180);
|
assert_eq!(ranking.telemetry_retention_days, 180);
|
||||||
cfg.validate().unwrap();
|
cfg.validate().unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -444,10 +444,10 @@ pub fn dot(left: &[f32], right: &[f32]) -> Result<f64, EmbeddingError> {
|
|||||||
|
|
||||||
/// The `limit` closest compatible cached article vectors, highest cosine first.
|
/// The `limit` closest compatible cached article vectors, highest cosine first.
|
||||||
///
|
///
|
||||||
/// A brute-force scan: the cache holds at most a few thousand rows after
|
/// A brute-force scan of every compatible row: fine for the admin dashboard,
|
||||||
/// pruning, so one statement plus a sort is cheap. The caller supplies the
|
/// too slow for reader pages (see [`crate::related`], which stores its
|
||||||
/// target row's model, dimension and decoded vector so pages that already
|
/// results). The caller supplies the target row's model, dimension and decoded
|
||||||
/// load that row do not query it a second time. Malformed candidate blobs are
|
/// vector so pages that already load that row do not query it a second time. Malformed candidate blobs are
|
||||||
/// ignored like malformed entries in the normal cache loader.
|
/// ignored like malformed entries in the normal cache loader.
|
||||||
pub async fn nearest_articles(
|
pub async fn nearest_articles(
|
||||||
db: &Db,
|
db: &Db,
|
||||||
|
|||||||
+44
-4
@@ -1058,7 +1058,7 @@ pub fn render_stats_text(data: &StatsData) -> String {
|
|||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub struct Pruned {
|
pub struct Pruned {
|
||||||
/// `article_embeddings` of unrated, unpublished articles past
|
/// `article_embeddings` of unrated, unpublished articles past
|
||||||
/// `embedding_retention_days`.
|
/// `embedding_retention_days`; always 0 when that is 0 (keep forever).
|
||||||
pub embeddings: u64,
|
pub embeddings: u64,
|
||||||
/// `candidate_runs` rows of runs past `telemetry_retention_days`.
|
/// `candidate_runs` rows of runs past `telemetry_retention_days`.
|
||||||
pub telemetry: u64,
|
pub telemetry: u64,
|
||||||
@@ -1067,7 +1067,8 @@ pub struct Pruned {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete `article_embeddings` for articles neither rated nor published that
|
/// Delete `article_embeddings` for articles neither rated nor published that
|
||||||
/// are older than `embedding_retention_days`, `candidate_runs` rows whose run
|
/// are older than `embedding_retention_days` (0 or less keeps every vector, so
|
||||||
|
/// related articles can reach the whole archive), `candidate_runs` rows whose run
|
||||||
/// started more than `telemetry_retention_days` ago, and `article_assessments`
|
/// started more than `telemetry_retention_days` ago, and `article_assessments`
|
||||||
/// older than the same window (§7.1, §7.4). Runs from `features prune` and
|
/// older than the same window (§7.1, §7.4). Runs from `features prune` and
|
||||||
/// once per `generate` after publishing.
|
/// once per `generate` after publishing.
|
||||||
@@ -1081,7 +1082,10 @@ pub async fn prune(
|
|||||||
now.checked_sub(jiff::Span::new().hours(days.max(0).saturating_mul(24)))
|
now.checked_sub(jiff::Span::new().hours(days.max(0).saturating_mul(24)))
|
||||||
.unwrap_or(Timestamp::UNIX_EPOCH)
|
.unwrap_or(Timestamp::UNIX_EPOCH)
|
||||||
};
|
};
|
||||||
let embeddings = sqlx::query(
|
let embeddings = if embedding_retention_days <= 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
sqlx::query(
|
||||||
"DELETE FROM article_embeddings
|
"DELETE FROM article_embeddings
|
||||||
WHERE article_id IN (
|
WHERE article_id IN (
|
||||||
SELECT ae.article_id
|
SELECT ae.article_id
|
||||||
@@ -1094,7 +1098,8 @@ pub async fn prune(
|
|||||||
.bind(fmt_ts(cutoff(embedding_retention_days)))
|
.bind(fmt_ts(cutoff(embedding_retention_days)))
|
||||||
.execute(db.pool())
|
.execute(db.pool())
|
||||||
.await?
|
.await?
|
||||||
.rows_affected();
|
.rows_affected()
|
||||||
|
};
|
||||||
|
|
||||||
let telemetry = sqlx::query(
|
let telemetry = sqlx::query(
|
||||||
"DELETE FROM candidate_runs
|
"DELETE FROM candidate_runs
|
||||||
@@ -1942,4 +1947,39 @@ mean generation time: n/a (0 runs)
|
|||||||
// A second pass finds nothing left to remove.
|
// A second pass finds nothing left to remove.
|
||||||
assert_eq!(prune(&db, 120, 180, now).await.unwrap(), Pruned::default());
|
assert_eq!(prune(&db, 120, 180, now).await.unwrap(), Pruned::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn zero_embedding_retention_keeps_every_vector() {
|
||||||
|
let (_dir, db) = db_with_articles(&[1, 2]).await;
|
||||||
|
let now = Timestamp::now();
|
||||||
|
let old = fmt_ts(now - jiff::Span::new().hours(2000 * 24));
|
||||||
|
sqlx::query("UPDATE articles SET first_seen = ?")
|
||||||
|
.bind(&old)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let blob = encode_blob(&[0.5, 0.5]).unwrap();
|
||||||
|
for id in 1..=2 {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO article_embeddings
|
||||||
|
(article_id, model, dimension, input_hash, embedding, created_at)
|
||||||
|
VALUES (?, 'voyage-4-lite', 2, 'h', ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&blob)
|
||||||
|
.bind(&old)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 must not mean "a cutoff of now", which would delete everything.
|
||||||
|
let pruned = prune(&db, 0, 180, now).await.unwrap();
|
||||||
|
assert_eq!(pruned.embeddings, 0);
|
||||||
|
let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM article_embeddings")
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(remaining, 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -150,9 +150,10 @@ struct ProviderCostLine {
|
|||||||
|
|
||||||
/// Prepare article markup for XHTML: rewrite images, sanitize, self-close voids.
|
/// Prepare article markup for XHTML: rewrite images, sanitize, self-close voids.
|
||||||
pub fn prepare_body(html: &str, images_: &[ImageAsset]) -> String {
|
pub fn prepare_body(html: &str, images_: &[ImageAsset]) -> String {
|
||||||
// Sanitize first: image rewriting emits our own trusted markup (including the
|
// Sanitize first: highlighting and image rewriting emit our own trusted
|
||||||
// `image-placeholder` class, which ammonia would otherwise strip).
|
// markup (the `hl-*` token classes and `image-placeholder`, which ammonia
|
||||||
let cleaned = ammonia::clean(html);
|
// would otherwise strip).
|
||||||
|
let cleaned = crate::highlight::clean_and_highlight(html);
|
||||||
let rewritten = images::rewrite_img_srcs(&cleaned, images_);
|
let rewritten = images::rewrite_img_srcs(&cleaned, images_);
|
||||||
to_xhtml(&rewritten)
|
to_xhtml(&rewritten)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,16 @@ figcaption,
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre, code {
|
/* The firmwares (CrossPoint, CrossInk) ignore `white-space` and treat `pre`
|
||||||
|
as inline, so the X4 simplifier makes each listing line a `p.code-line`;
|
||||||
|
the first and last lines carry the block's spacing, and a blank source
|
||||||
|
line becomes a `p.code-gap` margin. `figure` centres for
|
||||||
|
image captions, so lines reset alignment. The XTC converter's CREngine
|
||||||
|
defaults `code` to a block. */
|
||||||
|
pre {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@@ -169,6 +178,81 @@ pre, code {
|
|||||||
-webkit-hyphens: none;
|
-webkit-hyphens: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.code-line {
|
||||||
|
margin: 0;
|
||||||
|
text-indent: 0;
|
||||||
|
text-align: left;
|
||||||
|
hyphens: none;
|
||||||
|
-webkit-hyphens: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.code-first {
|
||||||
|
margin-top: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.code-last {
|
||||||
|
margin-bottom: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Where the source had a blank line: half a line, not a whole one. */
|
||||||
|
p.code-gap {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
display: inline;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
hyphens: none;
|
||||||
|
-webkit-hyphens: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Syntax tokens (src/highlight.rs), as in style.css but on the X4's four
|
||||||
|
levels only (#000, #555, #aaa, white), so glyphs are not dithered: ink for
|
||||||
|
names, dark grey for keywords, literals and punctuation, light grey for
|
||||||
|
comments. */
|
||||||
|
.hl-function,
|
||||||
|
.hl-macro,
|
||||||
|
.hl-title,
|
||||||
|
.hl-strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-type,
|
||||||
|
.hl-namespace,
|
||||||
|
.hl-constructor,
|
||||||
|
.hl-attribute,
|
||||||
|
.hl-emphasis {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-keyword,
|
||||||
|
.hl-tag {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-string {
|
||||||
|
color: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-number,
|
||||||
|
.hl-constant,
|
||||||
|
.hl-label {
|
||||||
|
font-style: italic;
|
||||||
|
color: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-punctuation,
|
||||||
|
.hl-operator {
|
||||||
|
color: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-comment {
|
||||||
|
font-style: italic;
|
||||||
|
color: #aaaaaa;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|||||||
@@ -221,13 +221,67 @@ hr.rule {
|
|||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CREngine readers (KOReader, PocketBook) default `code` to a block. */
|
||||||
|
.article-body code {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left even inside a `<figure>`, which centres for image captions. */
|
||||||
.article-body pre {
|
.article-body pre {
|
||||||
|
margin: 0.8em 0;
|
||||||
|
text-align: left;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
border-left: 2px solid #cccccc;
|
border-left: 2px solid #cccccc;
|
||||||
padding-left: 0.5em;
|
padding-left: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Syntax tokens (src/highlight.rs), monochrome for e-ink, after zenbones and
|
||||||
|
lackluster: what the code names stays in ink (functions bold, types italic),
|
||||||
|
the language's own furniture steps back to grey (keywords bold grey,
|
||||||
|
literals and punctuation grey), comments lightest. Flat selectors. */
|
||||||
|
.hl-function,
|
||||||
|
.hl-macro,
|
||||||
|
.hl-title,
|
||||||
|
.hl-strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-type,
|
||||||
|
.hl-namespace,
|
||||||
|
.hl-constructor,
|
||||||
|
.hl-attribute,
|
||||||
|
.hl-emphasis {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-keyword,
|
||||||
|
.hl-tag {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-string {
|
||||||
|
color: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-number,
|
||||||
|
.hl-constant,
|
||||||
|
.hl-label {
|
||||||
|
font-style: italic;
|
||||||
|
color: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-punctuation,
|
||||||
|
.hl-operator {
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-comment {
|
||||||
|
font-style: italic;
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
|
|
||||||
.article-body table {
|
.article-body table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|||||||
+415
-7
@@ -124,10 +124,295 @@ fn is_dropped_declaration(decl: &str) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Class of every paragraph [`paragraph_code_lines`] makes from a listing
|
||||||
|
/// line; the first and last also get [`CODE_FIRST_CLASS`] / [`CODE_LAST_CLASS`].
|
||||||
|
pub const CODE_LINE_CLASS: &str = "code-line";
|
||||||
|
pub const CODE_FIRST_CLASS: &str = "code-first";
|
||||||
|
pub const CODE_LAST_CLASS: &str = "code-last";
|
||||||
|
/// Marks a line followed by blank lines in the source; it gets a short gap.
|
||||||
|
pub const CODE_GAP_CLASS: &str = "code-gap";
|
||||||
|
|
||||||
/// Strip layout constructs the X4 renderer handles poorly from chapter markup,
|
/// Strip layout constructs the X4 renderer handles poorly from chapter markup,
|
||||||
/// then soft-hyphenate anything too long for it to wrap (§3.10).
|
/// then soft-hyphenate anything too long for it to wrap (§3.10).
|
||||||
pub fn simplify_xhtml(xhtml: &str) -> String {
|
pub fn simplify_xhtml(xhtml: &str) -> String {
|
||||||
break_long_words(&strip_attributes(xhtml, DROPPED_ATTRIBUTES))
|
paragraph_code_lines(&break_long_words(&strip_attributes(
|
||||||
|
&flatten_lossy_tables(xhtml),
|
||||||
|
DROPPED_ATTRIBUTES,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Table elements dropped outright when an image table is flattened.
|
||||||
|
const TABLE_FRAME_ELEMENTS: &[&str] =
|
||||||
|
&["table", "thead", "tbody", "tfoot", "tr", "colgroup", "col"];
|
||||||
|
|
||||||
|
/// Table elements whose content becomes a `<div>` when a table is flattened.
|
||||||
|
const TABLE_CELL_ELEMENTS: &[&str] = &["td", "th", "caption"];
|
||||||
|
|
||||||
|
/// Elements the X4 firmwares treat as images.
|
||||||
|
const IMAGE_ELEMENTS: &[&str] = &["img", "image"];
|
||||||
|
|
||||||
|
/// Turn every table the X4 firmwares would lose content from into plain
|
||||||
|
/// `<div>` blocks: one that holds an image or another table.
|
||||||
|
///
|
||||||
|
/// The firmwares (CrossPoint and CrossInk) never draw an image inside a table
|
||||||
|
/// cell: they print its alt text instead. Publishers that lay out a figure as a
|
||||||
|
/// one-cell table with the caption beneath the image (The Space Review, many
|
||||||
|
/// older sites) would lose every figure. CrossInk also throws away all the text
|
||||||
|
/// of a table nested in another. Flattening keeps each cell's content in order,
|
||||||
|
/// which is how the firmwares already fall back for any table they cannot lay
|
||||||
|
/// out as a grid. Tables of plain text stay tables.
|
||||||
|
fn flatten_lossy_tables(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len());
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
while let Some(rel) = html[cursor..].find('<') {
|
||||||
|
let start = cursor + rel;
|
||||||
|
out.push_str(&html[cursor..start]);
|
||||||
|
let Some(end) = tag_end(html, start) else {
|
||||||
|
out.push_str(&html[start..]);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
let tag = &html[start..end];
|
||||||
|
if let Some(name) = raw_text_name(tag) {
|
||||||
|
let close = find_close_tag(html, end, name).unwrap_or(html.len());
|
||||||
|
out.push_str(&html[start..close]);
|
||||||
|
cursor = close;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !is_element_tag(tag, "table", false) {
|
||||||
|
out.push_str(tag);
|
||||||
|
cursor = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let table_end = matching_table_end(html, start);
|
||||||
|
let table = &html[start..table_end];
|
||||||
|
// Past the outer `<table` tag, so it doesn't count as its own nesting.
|
||||||
|
let inner = &table[end - start..];
|
||||||
|
if contains_element(inner, IMAGE_ELEMENTS) || contains_element(inner, &["table"]) {
|
||||||
|
out.push_str(&flatten_table(table));
|
||||||
|
} else {
|
||||||
|
out.push_str(table);
|
||||||
|
}
|
||||||
|
cursor = table_end;
|
||||||
|
}
|
||||||
|
out.push_str(&html[cursor..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte offset just past the `</table>` that closes the table opened at
|
||||||
|
/// `start`, counting nested tables; the end of `html` if it is never closed.
|
||||||
|
fn matching_table_end(html: &str, start: usize) -> usize {
|
||||||
|
let mut depth = 0usize;
|
||||||
|
let mut cursor = start;
|
||||||
|
while let Some(rel) = html[cursor..].find('<') {
|
||||||
|
let tag_start = cursor + rel;
|
||||||
|
let Some(end) = tag_end(html, tag_start) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let tag = &html[tag_start..end];
|
||||||
|
if is_element_tag(tag, "table", false) && !tag.ends_with("/>") {
|
||||||
|
depth += 1;
|
||||||
|
} else if is_element_tag(tag, "table", true) {
|
||||||
|
depth = depth.saturating_sub(1);
|
||||||
|
if depth == 0 {
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
html.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any tag in `html` opens one of the named elements.
|
||||||
|
fn contains_element(html: &str, names: &[&str]) -> bool {
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
while let Some(rel) = html[cursor..].find('<') {
|
||||||
|
let start = cursor + rel;
|
||||||
|
let Some(end) = tag_end(html, start) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let tag = &html[start..end];
|
||||||
|
if names.iter().any(|name| is_element_tag(tag, name, false)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite one table's markup (nested tables included) as `<div>` blocks.
|
||||||
|
fn flatten_table(table: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(table.len());
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
while let Some(rel) = table[cursor..].find('<') {
|
||||||
|
let start = cursor + rel;
|
||||||
|
out.push_str(&table[cursor..start]);
|
||||||
|
let Some(end) = tag_end(table, start) else {
|
||||||
|
out.push_str(&table[start..]);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
let tag = &table[start..end];
|
||||||
|
cursor = end;
|
||||||
|
let is = |names: &[&str], closing: bool| {
|
||||||
|
names.iter().any(|name| is_element_tag(tag, name, closing))
|
||||||
|
};
|
||||||
|
if is(TABLE_FRAME_ELEMENTS, false) || is(TABLE_FRAME_ELEMENTS, true) {
|
||||||
|
// A newline, not nothing: the firmwares buffer the last word
|
||||||
|
// before an element boundary until whitespace arrives.
|
||||||
|
out.push('\n');
|
||||||
|
} else if is(TABLE_CELL_ELEMENTS, false) {
|
||||||
|
out.push_str(if tag.ends_with("/>") {
|
||||||
|
"<div></div>"
|
||||||
|
} else {
|
||||||
|
"<div>"
|
||||||
|
});
|
||||||
|
} else if is(TABLE_CELL_ELEMENTS, true) {
|
||||||
|
out.push_str("</div>");
|
||||||
|
} else {
|
||||||
|
out.push_str(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(&table[cursor..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn each line of every `<pre>` into its own `<p class="code-line">`.
|
||||||
|
///
|
||||||
|
/// The X4 firmwares (CrossPoint and forks such as CrossInk) ignore
|
||||||
|
/// `white-space` and lay out only `p`, `div`, `li`, `blockquote`, lists and
|
||||||
|
/// `br` as blocks: a `<pre>` is inline, so its margins never apply and its
|
||||||
|
/// `<br/>` lines are handled by each firmware's own rules. CrossInk glues the
|
||||||
|
/// first line onto the paragraph before, and empty `<br/>` lines count as scene
|
||||||
|
/// breaks. A paragraph per line takes the one path every firmware lays out the
|
||||||
|
/// same way. The first and last lines carry the block's margins; blank lines
|
||||||
|
/// become a half-line gap (see [`code_paragraphs`]).
|
||||||
|
///
|
||||||
|
/// Inline elements open across a line break (a `<code>`, a multi-line
|
||||||
|
/// comment's span) are closed at the end of the line and reopened at the start
|
||||||
|
/// of the next, so every paragraph is well formed.
|
||||||
|
fn paragraph_code_lines(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len() + 256);
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
while let Some(rel) = html[cursor..].find('<') {
|
||||||
|
let start = cursor + rel;
|
||||||
|
out.push_str(&html[cursor..start]);
|
||||||
|
let Some(end) = tag_end(html, start) else {
|
||||||
|
out.push_str(&html[start..]);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
let tag = &html[start..end];
|
||||||
|
out.push_str(tag);
|
||||||
|
cursor = end;
|
||||||
|
if !is_element_tag(tag, "pre", false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(close) = find_close_tag(html, cursor, "pre") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
out.push_str(&code_paragraphs(&html[cursor..close]));
|
||||||
|
cursor = close;
|
||||||
|
}
|
||||||
|
out.push_str(&html[cursor..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The paragraphs for one `<pre>` body whose line breaks are `<br/>`.
|
||||||
|
fn code_paragraphs(body: &str) -> String {
|
||||||
|
// Each line's markup, plus whether it shows any text.
|
||||||
|
let mut lines: Vec<(String, bool)> = Vec::new();
|
||||||
|
// Inline elements open at this point: (name, opening tag).
|
||||||
|
let mut open: Vec<(String, &str)> = Vec::new();
|
||||||
|
let mut line = String::new();
|
||||||
|
let mut visible = false;
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
let finish = |line: &mut String, visible: &mut bool, open: &[(String, &str)]| {
|
||||||
|
let mut done = std::mem::take(line);
|
||||||
|
if !*visible {
|
||||||
|
done.push(NO_BREAK_SPACE);
|
||||||
|
}
|
||||||
|
for (name, _) in open.iter().rev() {
|
||||||
|
done.push_str(&format!("</{name}>"));
|
||||||
|
}
|
||||||
|
let shown = std::mem::replace(visible, false);
|
||||||
|
for (_, tag) in open {
|
||||||
|
line.push_str(tag);
|
||||||
|
}
|
||||||
|
(done, shown)
|
||||||
|
};
|
||||||
|
while cursor < body.len() {
|
||||||
|
let Some(rel) = body[cursor..].find('<') else {
|
||||||
|
let text = &body[cursor..];
|
||||||
|
visible |= shows_text(text);
|
||||||
|
line.push_str(text);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let start = cursor + rel;
|
||||||
|
let text = &body[cursor..start];
|
||||||
|
visible |= shows_text(text);
|
||||||
|
line.push_str(text);
|
||||||
|
let Some(end) = tag_end(body, start) else {
|
||||||
|
line.push_str(&body[start..]);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let tag = &body[start..end];
|
||||||
|
cursor = end;
|
||||||
|
let inner = tag.trim_start_matches('<').trim_end_matches('>');
|
||||||
|
let name = crate::html::tag_name(inner);
|
||||||
|
if name == "br" {
|
||||||
|
lines.push(finish(&mut line, &mut visible, &open));
|
||||||
|
} else if inner.starts_with('/') {
|
||||||
|
if let Some(at) = open.iter().rposition(|(open_name, _)| *open_name == name) {
|
||||||
|
open.remove(at);
|
||||||
|
}
|
||||||
|
line.push_str(tag);
|
||||||
|
} else {
|
||||||
|
if !inner.trim_end().ends_with('/') {
|
||||||
|
open.push((name, tag));
|
||||||
|
}
|
||||||
|
line.push_str(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(finish(&mut line, &mut visible, &open));
|
||||||
|
// Blank lines become a short gap after the line before them rather than
|
||||||
|
// paragraphs of their own: with the firmware's paragraph spacing on, a
|
||||||
|
// blank paragraph adds a full line plus two half-line gaps and reads as a
|
||||||
|
// double newline. Leading, trailing and repeated blanks collapse.
|
||||||
|
let mut kept: Vec<(String, bool)> = Vec::with_capacity(lines.len());
|
||||||
|
for (line, shown) in lines {
|
||||||
|
if shown {
|
||||||
|
kept.push((line, false));
|
||||||
|
} else if let Some(previous) = kept.last_mut() {
|
||||||
|
previous.1 = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kept.is_empty() {
|
||||||
|
// An all-blank listing still occupies one line.
|
||||||
|
kept.push((NO_BREAK_SPACE.to_string(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
let last = kept.len() - 1;
|
||||||
|
let mut out = String::with_capacity(body.len() + kept.len() * 48);
|
||||||
|
for (index, (line, gap_after)) in kept.into_iter().enumerate() {
|
||||||
|
let mut class = String::from(CODE_LINE_CLASS);
|
||||||
|
if index == 0 {
|
||||||
|
class.push(' ');
|
||||||
|
class.push_str(CODE_FIRST_CLASS);
|
||||||
|
}
|
||||||
|
if index == last {
|
||||||
|
class.push(' ');
|
||||||
|
class.push_str(CODE_LAST_CLASS);
|
||||||
|
} else if gap_after {
|
||||||
|
class.push(' ');
|
||||||
|
class.push_str(CODE_GAP_CLASS);
|
||||||
|
}
|
||||||
|
out.push_str(&format!("<p class=\"{class}\">{line}</p>"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a run of character data shows anything but spaces.
|
||||||
|
fn shows_text(text: &str) -> bool {
|
||||||
|
text.chars()
|
||||||
|
.any(|c| !c.is_whitespace() && c != NO_BREAK_SPACE)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert soft hyphens into over-long words and make `<pre>` whitespace
|
/// Insert soft hyphens into over-long words and make `<pre>` whitespace
|
||||||
@@ -609,7 +894,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pre_whitespace_is_made_explicit_inside_nested_tags() {
|
fn pre_whitespace_is_made_explicit_inside_nested_tags() {
|
||||||
let input = "<pre><code>fn main() {\n if x < 2 {\n <em>return</em>;\n }\n <strong><a href=\"#\">// done</a></strong>\n}</code></pre>";
|
let input = "<pre><code>fn main() {\n if x < 2 {\n <em>return</em>;\n }\n <strong><a href=\"#\">// done</a></strong>\n}</code></pre>";
|
||||||
let out = simplify_xhtml(input);
|
let out = break_long_words(input);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
out,
|
out,
|
||||||
concat!(
|
concat!(
|
||||||
@@ -622,10 +907,40 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_code_line_becomes_a_paragraph() {
|
||||||
|
let input = "<p>Before.</p><figure><pre><code>let a = 1;\n\n let b = 2;</code></pre></figure><p>After.</p>";
|
||||||
|
assert_eq!(
|
||||||
|
simplify_xhtml(input),
|
||||||
|
concat!(
|
||||||
|
"<p>Before.</p><figure><pre>",
|
||||||
|
"<p class=\"code-line code-first code-gap\"><code>let a = 1;</code></p>",
|
||||||
|
"<p class=\"code-line code-last\"><code>\u{00a0}\u{00a0}let b = 2;</code></p>",
|
||||||
|
"</pre></figure><p>After.</p>"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// `<pre` as a prefix of another name is not a code block.
|
||||||
|
assert_eq!(simplify_xhtml("<prefix>x</prefix>"), "<prefix>x</prefix>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spans_open_across_a_line_break_are_closed_and_reopened() {
|
||||||
|
let input = "<pre><code><span class=\"hl-comment\">/* one\ntwo */</span> x\n</code></pre>";
|
||||||
|
assert_eq!(
|
||||||
|
simplify_xhtml(input),
|
||||||
|
concat!(
|
||||||
|
"<pre>",
|
||||||
|
"<p class=\"code-line code-first\"><code><span class=\"hl-comment\">/* one</span></code></p>",
|
||||||
|
"<p class=\"code-line code-last\"><code><span class=\"hl-comment\">two */</span> x</code></p>",
|
||||||
|
"</pre>"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tabs_in_pre_become_four_no_break_spaces() {
|
fn tabs_in_pre_become_four_no_break_spaces() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
simplify_xhtml("<pre><code>one\ttwo</code></pre>"),
|
break_long_words("<pre><code>one\ttwo</code></pre>"),
|
||||||
"<pre><code>one\u{00a0}\u{00a0}\u{00a0}\u{00a0}two</code></pre>"
|
"<pre><code>one\u{00a0}\u{00a0}\u{00a0}\u{00a0}two</code></pre>"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -634,7 +949,7 @@ mod tests {
|
|||||||
fn whitespace_outside_pre_is_untouched() {
|
fn whitespace_outside_pre_is_untouched() {
|
||||||
let input = "<p>outside text\n next\tword</p><pre>x y</pre><p>tail text</p>";
|
let input = "<p>outside text\n next\tword</p><pre>x y</pre><p>tail text</p>";
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
simplify_xhtml(input),
|
break_long_words(input),
|
||||||
"<p>outside text\n next\tword</p><pre>x\u{00a0}\u{00a0}y</pre><p>tail text</p>"
|
"<p>outside text\n next\tword</p><pre>x\u{00a0}\u{00a0}y</pre><p>tail text</p>"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -642,11 +957,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn newline_right_after_pre_open_is_not_a_blank_line() {
|
fn newline_right_after_pre_open_is_not_a_blank_line() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
simplify_xhtml("<pre><code>\nlet a = 1;\n\nlet b = 2;</code></pre>"),
|
break_long_words("<pre><code>\nlet a = 1;\n\nlet b = 2;</code></pre>"),
|
||||||
"<pre><code>let a = 1;<br/><br/>let b = 2;</code></pre>"
|
"<pre><code>let a = 1;<br/><br/>let b = 2;</code></pre>"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
simplify_xhtml("<pre>\n x</pre>"),
|
break_long_words("<pre>\n x</pre>"),
|
||||||
"<pre>\u{00a0}\u{00a0}x</pre>"
|
"<pre>\u{00a0}\u{00a0}x</pre>"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -654,7 +969,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pre_without_a_trailing_newline_gets_no_extra_break() {
|
fn pre_without_a_trailing_newline_gets_no_extra_break() {
|
||||||
let input = "<pre><code>let answer = 42;</code></pre>";
|
let input = "<pre><code>let answer = 42;</code></pre>";
|
||||||
assert_eq!(simplify_xhtml(input), input);
|
assert_eq!(break_long_words(input), input);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The shipped X4 stylesheet must already satisfy the X4 rules, so the
|
/// The shipped X4 stylesheet must already satisfy the X4 rules, so the
|
||||||
@@ -794,4 +1109,97 @@ mod tests {
|
|||||||
"<?xml version=\"1.0\"?>\n<!DOCTYPE html>\n<html><body><p>2 < 3</p></body></html>";
|
"<?xml version=\"1.0\"?>\n<!DOCTYPE html>\n<html><body><p>2 < 3</p></body></html>";
|
||||||
assert_eq!(simplify_xhtml(input), input);
|
assert_eq!(simplify_xhtml(input), input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether any image in `html` sits inside a table, where the X4
|
||||||
|
/// firmwares print its alt text instead of drawing it.
|
||||||
|
fn image_inside_table(html: &str) -> bool {
|
||||||
|
let mut depth = 0usize;
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
while let Some(rel) = html[cursor..].find('<') {
|
||||||
|
let start = cursor + rel;
|
||||||
|
let end = tag_end(html, start).unwrap();
|
||||||
|
let tag = &html[start..end];
|
||||||
|
if is_element_tag(tag, "table", false) {
|
||||||
|
depth += 1;
|
||||||
|
} else if is_element_tag(tag, "table", true) {
|
||||||
|
depth -= 1;
|
||||||
|
} else if depth > 0 && IMAGE_ELEMENTS.iter().any(|n| is_element_tag(tag, n, false)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_figure_laid_out_as_a_table_becomes_divs() {
|
||||||
|
// The Space Review's figure markup (issue 2026-10-01).
|
||||||
|
let input = "<p>Before.</p><table><tbody><tr><td><img src=\"images/a.jpg\" alt=\"ELINT images\"/><p>During the 1970s. (credit: NRO)</p></td></tr></tbody></table><h3>Next</h3>";
|
||||||
|
let out = simplify_xhtml(input);
|
||||||
|
assert!(!image_inside_table(&out), "{out}");
|
||||||
|
assert!(!out.contains("<t"), "{out}");
|
||||||
|
assert!(
|
||||||
|
out.contains("<div><img src=\"images/a.jpg\" alt=\"ELINT images\"/><p>During the 1970s. (credit: NRO)</p></div>"),
|
||||||
|
"{out}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.starts_with("<p>Before.</p>") && out.ends_with("<h3>Next</h3>"),
|
||||||
|
"{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_cell_of_an_image_table_keeps_its_content_in_order() {
|
||||||
|
let input = "<table class=\"x\" width=\"600\"><caption>Cap</caption><thead><tr><th>A</th><th>B</th></tr></thead><tr><td colspan=\"2\"><img src=\"1.jpg\"/></td></tr><tr><td>left</td><td>right</td></tr></table>";
|
||||||
|
let out = flatten_lossy_tables(input);
|
||||||
|
assert_eq!(
|
||||||
|
out.split_whitespace().collect::<String>(),
|
||||||
|
"<div>Cap</div><div>A</div><div>B</div><div><imgsrc=\"1.jpg\"/></div><div>left</div><div>right</div>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_image_tables_flatten_from_the_outside_in() {
|
||||||
|
for input in [
|
||||||
|
// Image in the inner table only.
|
||||||
|
"<table><tr><td><table><tr><td><img src=\"1.jpg\"/></td></tr></table></td></tr></table><p>after</p>",
|
||||||
|
// Image in the outer table after a text-only inner table.
|
||||||
|
"<table><tr><td><table><tr><td>x</td></tr></table><img src=\"1.jpg\"/></td></tr></table><p>after</p>",
|
||||||
|
// An SVG `<image>` counts too.
|
||||||
|
"<table><tr><td><svg><image href=\"1.jpg\"/></svg></td></tr></table><p>after</p>",
|
||||||
|
] {
|
||||||
|
let out = simplify_xhtml(input);
|
||||||
|
assert!(!image_inside_table(&out), "{out}");
|
||||||
|
assert!(!out.contains("<table"), "{out}");
|
||||||
|
assert!(out.ends_with("<p>after</p>"), "{out}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_nested_text_table_flattens_its_outer_table() {
|
||||||
|
let input = "<table><tr><td>outer<table><tr><td>inner</td></tr></table></td></tr></table>";
|
||||||
|
let out = flatten_lossy_tables(input);
|
||||||
|
assert_eq!(
|
||||||
|
out.split_whitespace().collect::<String>(),
|
||||||
|
"<div>outer<div>inner</div></div>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_tables_stay_tables() {
|
||||||
|
let input = "<table><tr><th>Year</th><td>1971</td></tr></table><p><img src=\"1.jpg\"/></p><table><tr><td>2</td></tr></table>";
|
||||||
|
assert_eq!(flatten_lossy_tables(input), input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_markup_inside_raw_text_is_left_alone() {
|
||||||
|
let input = "<script>let t = '<table><td><img src=x></td></table>';</script>";
|
||||||
|
assert_eq!(flatten_lossy_tables(input), input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unclosed_image_table_still_flattens() {
|
||||||
|
let out = flatten_lossy_tables("<table><tr><td><img src=\"1.jpg\"/>");
|
||||||
|
assert!(!image_inside_table(&out), "{out}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+28
-3
@@ -15,6 +15,7 @@ use std::time::Duration;
|
|||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::highlight::annotate_hints;
|
||||||
use crate::html::word_count;
|
use crate::html::word_count;
|
||||||
use crate::images::normalize::{normalize_img_tags, prepare_for_readability};
|
use crate::images::normalize::{normalize_img_tags, prepare_for_readability};
|
||||||
use crate::images::refs::collect_image_urls;
|
use crate::images::refs::collect_image_urls;
|
||||||
@@ -338,7 +339,8 @@ fn readable_page(
|
|||||||
html: &str,
|
html: &str,
|
||||||
url: &str,
|
url: &str,
|
||||||
) -> Result<(String, String, Option<String>, Option<String>), ExtractError> {
|
) -> Result<(String, String, Option<String>, Option<String>), ExtractError> {
|
||||||
let html = prepare_for_readability(html);
|
// Readability strips classes, and with them code blocks' language hints.
|
||||||
|
let html = prepare_for_readability(&annotate_hints(html));
|
||||||
let config = dom_smoothie::Config {
|
let config = dom_smoothie::Config {
|
||||||
max_elements_to_parse: 60_000,
|
max_elements_to_parse: 60_000,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -447,6 +449,7 @@ fn builder() -> ammonia::Builder<'static> {
|
|||||||
.generic_attributes(HashSet::new())
|
.generic_attributes(HashSet::new())
|
||||||
.link_rel(None)
|
.link_rel(None)
|
||||||
.strip_comments(true);
|
.strip_comments(true);
|
||||||
|
crate::highlight::keep_lang_attr(&mut b);
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,8 +457,10 @@ fn builder() -> ammonia::Builder<'static> {
|
|||||||
///
|
///
|
||||||
/// Allowed: `p`, `h1`–`h4`, `ul`/`ol`/`li`, `blockquote`, `pre`, `code`, `em`,
|
/// Allowed: `p`, `h1`–`h4`, `ul`/`ol`/`li`, `blockquote`, `pre`, `code`, `em`,
|
||||||
/// `strong`, `a`, `img`, `figure`, `figcaption`, table basics, `hr`, `br`.
|
/// `strong`, `a`, `img`, `figure`, `figcaption`, table basics, `hr`, `br`.
|
||||||
|
/// The one attribute kept beyond links and images is a code block's language,
|
||||||
|
/// as `<pre data-lang>` (see [`crate::highlight`]).
|
||||||
pub fn sanitize(html: &str) -> String {
|
pub fn sanitize(html: &str) -> String {
|
||||||
builder().clean(html).to_string()
|
builder().clean(&annotate_hints(html)).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`sanitize`], additionally rewriting relative `href`/`src` against `base_url`
|
/// [`sanitize`], additionally rewriting relative `href`/`src` against `base_url`
|
||||||
@@ -464,7 +469,7 @@ pub fn sanitize_with_base(html: &str, base_url: &str) -> String {
|
|||||||
match Url::parse(base_url) {
|
match Url::parse(base_url) {
|
||||||
Ok(base) => builder()
|
Ok(base) => builder()
|
||||||
.url_relative(ammonia::UrlRelative::RewriteWithBase(base))
|
.url_relative(ammonia::UrlRelative::RewriteWithBase(base))
|
||||||
.clean(html)
|
.clean(&annotate_hints(html))
|
||||||
.to_string(),
|
.to_string(),
|
||||||
Err(_) => sanitize(html),
|
Err(_) => sanitize(html),
|
||||||
}
|
}
|
||||||
@@ -546,6 +551,26 @@ mod tests {
|
|||||||
format!("<p>{}</p>", "lorem ".repeat(words))
|
format!("<p>{}</p>", "lorem ".repeat(words))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_keeps_code_block_languages_as_data_lang_only() {
|
||||||
|
let dirty = r#"<div class="language-ruby highlighter-rouge"><pre class="highlight"><code>puts 1</code></pre></div>
|
||||||
|
<pre><code class="language-rust" data-x="y">fn main() {}</code></pre>
|
||||||
|
<pre data-lang="" onload="x">forged</pre>
|
||||||
|
<p data-lang="rust">not a block</p>"#;
|
||||||
|
let clean = sanitize(dirty);
|
||||||
|
assert!(
|
||||||
|
clean.contains(r#"<pre data-lang="ruby"><code>puts 1</code></pre>"#),
|
||||||
|
"{clean}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
clean.contains(r#"<pre data-lang="rust"><code>fn main() {}</code></pre>"#),
|
||||||
|
"{clean}"
|
||||||
|
);
|
||||||
|
assert!(clean.contains("<pre>forged</pre>"), "{clean}");
|
||||||
|
assert!(clean.contains("<p>not a block</p>"), "{clean}");
|
||||||
|
assert!(!clean.contains("class="));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_keeps_the_allowlist_and_drops_everything_else() {
|
fn sanitize_keeps_the_allowlist_and_drops_everything_else() {
|
||||||
let dirty = r#"
|
let dirty = r#"
|
||||||
|
|||||||
@@ -0,0 +1,743 @@
|
|||||||
|
//! Syntax highlighting for code blocks in article bodies.
|
||||||
|
//!
|
||||||
|
//! Two halves, run at different times:
|
||||||
|
//!
|
||||||
|
//! * [`annotate_hints`] runs on source markup at extraction, before
|
||||||
|
//! readability and the sanitizer strip every `class`. It reads whatever
|
||||||
|
//! language hint the page carries (`language-rust`, Rouge's wrapper `<div>`,
|
||||||
|
//! SyntaxHighlighter's `brush: py`, `data-lang`, …) and records it as one
|
||||||
|
//! normalized `data-lang` on the `<pre>`, which both of them keep.
|
||||||
|
//! * [`highlight_blocks`] runs at render time, on sanitized markup, for the web
|
||||||
|
//! article page and both EPUB editions. Blocks with a hint use it; blocks
|
||||||
|
//! without one are guessed from their content ([`guess_language`]), and
|
||||||
|
//! anything not confidently code is left exactly as it was.
|
||||||
|
//!
|
||||||
|
//! Tokens come out as flat `<span class="hl-…">` elements, one class each, so
|
||||||
|
//! the X4 stylesheet (no descendant selectors) can style them too. Colours are
|
||||||
|
//! the stylesheets' business: the web theme is in `src/web/tailwind.css`, the
|
||||||
|
//! monochrome e-ink themes in `src/epub/templates/style*.css`.
|
||||||
|
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
|
use arborium::{Config, GrammarStore, Highlighter, HtmlFormat};
|
||||||
|
|
||||||
|
use crate::html::{attr_escape, decode_entities, parse_attrs, tag_end, tag_name};
|
||||||
|
|
||||||
|
/// The attribute that carries a block's language from extraction to render.
|
||||||
|
pub const LANG_ATTR: &str = "data-lang";
|
||||||
|
|
||||||
|
/// The `data-lang` value for a block its source marked as not code (plain
|
||||||
|
/// text, program output): never highlighted, never guessed.
|
||||||
|
pub const PLAIN: &str = "text";
|
||||||
|
|
||||||
|
/// Prefix of every token class, e.g. `hl-keyword`.
|
||||||
|
const CLASS_PREFIX: &str = "hl";
|
||||||
|
|
||||||
|
/// Hints that mean "this is not source code".
|
||||||
|
const PLAIN_HINTS: &[&str] = &[
|
||||||
|
"text",
|
||||||
|
"txt",
|
||||||
|
"plain",
|
||||||
|
"plaintext",
|
||||||
|
"none",
|
||||||
|
"nohighlight",
|
||||||
|
"no-highlight",
|
||||||
|
"output",
|
||||||
|
"console",
|
||||||
|
"terminal",
|
||||||
|
"log",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Attributes that name a block's language outright.
|
||||||
|
const LANG_ATTRIBUTES: &[&str] = &[
|
||||||
|
"data-lang",
|
||||||
|
"data-language",
|
||||||
|
"data-code-block-lang",
|
||||||
|
"data-code-language",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Class prefixes highlighters put in front of a language name.
|
||||||
|
const CLASS_PREFIXES: &[&str] = &[
|
||||||
|
"language-",
|
||||||
|
"lang-",
|
||||||
|
"highlight-source-",
|
||||||
|
"highlight-",
|
||||||
|
"source-",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Minimum model confidence for a guessed language. Measured on short real
|
||||||
|
/// snippets, guesses at or above this were right; below it they were coin flips.
|
||||||
|
const MIN_GUESS_SCORE: f32 = 0.5;
|
||||||
|
|
||||||
|
/// Unhinted blocks shorter than this are left alone: one-liners are usually a
|
||||||
|
/// command or a path, and too short to classify.
|
||||||
|
const MIN_GUESS_CHARS: usize = 40;
|
||||||
|
|
||||||
|
/// A block that is only plausibly code is weighed against the article's
|
||||||
|
/// prevailing language using at most this many of its best candidates…
|
||||||
|
const MAX_CANDIDATES: usize = 3;
|
||||||
|
|
||||||
|
/// …each scoring at least this much.
|
||||||
|
const MIN_CANDIDATE_SCORE: f32 = 0.1;
|
||||||
|
|
||||||
|
/// The detector reads at most this much of a block.
|
||||||
|
const MAX_GUESS_BYTES: usize = 4096;
|
||||||
|
|
||||||
|
/// Compiled grammars, shared across threads; each call gets its own parser.
|
||||||
|
static GRAMMARS: LazyLock<Arc<GrammarStore>> = LazyLock::new(|| Arc::new(GrammarStore::new()));
|
||||||
|
|
||||||
|
fn new_highlighter() -> Highlighter {
|
||||||
|
Highlighter::with_store_and_config(
|
||||||
|
GRAMMARS.clone(),
|
||||||
|
Config {
|
||||||
|
html_format: HtmlFormat::ClassNamesWithPrefix(CLASS_PREFIX.into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `name` (a language or one of arborium's aliases) has a grammar.
|
||||||
|
fn supported(name: &str) -> bool {
|
||||||
|
GRAMMARS.get(name).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hints (extraction time)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Normalize one hint token (`language-rust`, `py`, `nohighlight`) to the
|
||||||
|
/// value stored in `data-lang`: a supported language name or [`PLAIN`].
|
||||||
|
///
|
||||||
|
/// A bare token (no `language-` style prefix) must be at least two characters,
|
||||||
|
/// so stray one-letter classes don't turn into C or R.
|
||||||
|
pub fn normalize_hint(token: &str) -> Option<String> {
|
||||||
|
let token = token.trim().trim_matches(';').to_ascii_lowercase();
|
||||||
|
let (name, prefixed) = CLASS_PREFIXES
|
||||||
|
.iter()
|
||||||
|
.find_map(|p| token.strip_prefix(p).map(|rest| (rest, true)))
|
||||||
|
.unwrap_or((token.as_str(), false));
|
||||||
|
if name.is_empty() || (!prefixed && name.len() < 2) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if PLAIN_HINTS.contains(&name) {
|
||||||
|
return Some(PLAIN.to_string());
|
||||||
|
}
|
||||||
|
let name = canonical_name(name);
|
||||||
|
supported(name).then(|| name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name detection uses for the common aliases a hint may carry, so a
|
||||||
|
/// `py` block and a guessed `python` block count as the same language.
|
||||||
|
fn canonical_name(name: &str) -> &str {
|
||||||
|
match name {
|
||||||
|
"py" | "python3" => "python",
|
||||||
|
"js" | "node" | "jsx" => "javascript",
|
||||||
|
"ts" => "typescript",
|
||||||
|
"sh" | "shell" => "bash",
|
||||||
|
"rs" => "rust",
|
||||||
|
"golang" => "go",
|
||||||
|
"rb" => "ruby",
|
||||||
|
"yml" => "yaml",
|
||||||
|
"c++" | "cxx" | "cc" | "hpp" => "cpp",
|
||||||
|
"cs" | "csharp" | "c#" => "c-sharp",
|
||||||
|
"objective-c" | "objectivec" => "objc",
|
||||||
|
"kt" => "kotlin",
|
||||||
|
"ex" | "exs" => "elixir",
|
||||||
|
"hs" => "haskell",
|
||||||
|
"ps1" | "pwsh" => "powershell",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The language hint carried by one element's attributes, if any.
|
||||||
|
fn hint_from_attrs(attrs: &[(String, String)]) -> Option<String> {
|
||||||
|
for (name, value) in attrs {
|
||||||
|
if LANG_ATTRIBUTES.contains(&name.as_str())
|
||||||
|
&& let Some(hint) = normalize_hint(value)
|
||||||
|
{
|
||||||
|
return Some(hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let class = attrs.iter().find(|(name, _)| name == "class")?.1.as_str();
|
||||||
|
// SyntaxHighlighter writes `class="brush: python; gutter: false"`.
|
||||||
|
let tokens: Vec<&str> = class
|
||||||
|
.split(|c: char| c.is_whitespace() || c == ';')
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect();
|
||||||
|
for (i, token) in tokens.iter().enumerate() {
|
||||||
|
let candidate = match token.strip_prefix("brush:") {
|
||||||
|
Some("") => tokens.get(i + 1).copied(),
|
||||||
|
Some(rest) => Some(rest),
|
||||||
|
None if i > 0 && tokens[i - 1] == "brush:" => None,
|
||||||
|
None => Some(*token),
|
||||||
|
};
|
||||||
|
if let Some(hint) = candidate.and_then(normalize_hint) {
|
||||||
|
return Some(hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record each `<pre>`'s language hint as `data-lang`, before readability and
|
||||||
|
/// the sanitizer strip the classes that carry it.
|
||||||
|
///
|
||||||
|
/// The hint may sit on the `<pre>`, on the `<code>` right inside it, or on an
|
||||||
|
/// enclosing `<div>` (Jekyll's Rouge, GitHub's `highlight-source-*`). Markup is
|
||||||
|
/// otherwise returned byte-identical.
|
||||||
|
pub fn annotate_hints(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len() + 64);
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
// Hint of every open `<div>`, innermost last.
|
||||||
|
let mut divs: Vec<Option<String>> = Vec::new();
|
||||||
|
while let Some(rel) = html[cursor..].find('<') {
|
||||||
|
let start = cursor + rel;
|
||||||
|
out.push_str(&html[cursor..start]);
|
||||||
|
let Some(end) = tag_end(html, start) else {
|
||||||
|
out.push_str(&html[start..]);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
let raw = &html[start..end];
|
||||||
|
cursor = end;
|
||||||
|
let inner = raw.trim_start_matches('<').trim_end_matches('>');
|
||||||
|
let name = tag_name(inner);
|
||||||
|
if inner.starts_with('/') {
|
||||||
|
if name == "div" {
|
||||||
|
divs.pop();
|
||||||
|
}
|
||||||
|
out.push_str(raw);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match name.as_str() {
|
||||||
|
"div" => {
|
||||||
|
if !inner.trim_end().ends_with('/') {
|
||||||
|
divs.push(hint_from_attrs(&parse_attrs(inner)));
|
||||||
|
}
|
||||||
|
out.push_str(raw);
|
||||||
|
}
|
||||||
|
"pre" => {
|
||||||
|
let attrs = parse_attrs(inner.trim_end_matches('/'));
|
||||||
|
let hint = hint_from_attrs(&attrs)
|
||||||
|
.or_else(|| first_code_hint(html, end))
|
||||||
|
.or_else(|| divs.iter().rev().find_map(Clone::clone));
|
||||||
|
match hint {
|
||||||
|
Some(hint) => out.push_str(&pre_tag(&attrs, &hint)),
|
||||||
|
None => out.push_str(raw),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => out.push_str(raw),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(&html[cursor..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hint on a `<code>` that opens right after a `<pre>` ending at `after`.
|
||||||
|
fn first_code_hint(html: &str, after: usize) -> Option<String> {
|
||||||
|
let rest = &html[after..];
|
||||||
|
let start = after + (rest.len() - rest.trim_start().len());
|
||||||
|
if !html[start..].starts_with('<') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let end = tag_end(html, start)?;
|
||||||
|
let inner = html[start..end]
|
||||||
|
.trim_start_matches('<')
|
||||||
|
.trim_end_matches('>');
|
||||||
|
(tag_name(inner) == "code").then(|| hint_from_attrs(&parse_attrs(inner)))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild a `<pre>` tag with `data-lang` set to `hint`.
|
||||||
|
fn pre_tag(attrs: &[(String, String)], hint: &str) -> String {
|
||||||
|
let mut tag = String::from("<pre");
|
||||||
|
for (name, value) in attrs {
|
||||||
|
if LANG_ATTRIBUTES.contains(&name.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tag.push_str(&format!(" {name}=\"{}\"", attr_escape(value)));
|
||||||
|
}
|
||||||
|
tag.push_str(&format!(" {LANG_ATTR}=\"{}\">", attr_escape(hint)));
|
||||||
|
tag
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitizer filter for `data-lang`: keep it only if it is a value
|
||||||
|
/// [`annotate_hints`] could have written.
|
||||||
|
pub fn filter_lang_attr(value: &str) -> Option<String> {
|
||||||
|
normalize_hint(value).filter(|hint| hint == &value.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Let an ammonia builder keep a valid `data-lang` on `<pre>` (and nothing else
|
||||||
|
/// it did not already allow).
|
||||||
|
pub fn keep_lang_attr(builder: &mut ammonia::Builder<'_>) {
|
||||||
|
builder
|
||||||
|
.add_tag_attributes("pre", &[LANG_ATTR])
|
||||||
|
.attribute_filter(|element, attribute, value| {
|
||||||
|
if element == "pre" && attribute == LANG_ATTR {
|
||||||
|
filter_lang_attr(value).map(Cow::Owned)
|
||||||
|
} else {
|
||||||
|
Some(Cow::Borrowed(value))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ammonia::clean` that keeps block languages, then [`highlight_blocks`]:
|
||||||
|
/// the render-time entry point for article bodies.
|
||||||
|
pub fn clean_and_highlight(html: &str) -> String {
|
||||||
|
let mut builder = ammonia::Builder::default();
|
||||||
|
keep_lang_attr(&mut builder);
|
||||||
|
highlight_blocks(&builder.clean(html).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// What an unhinted block's content says about its language.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
enum Detected {
|
||||||
|
/// Confidently this language.
|
||||||
|
Sure(&'static str),
|
||||||
|
/// Plausibly one of these, best first: taken only when the rest of the
|
||||||
|
/// article is in one of them (see [`highlight_blocks`]).
|
||||||
|
Maybe(Vec<&'static str>),
|
||||||
|
/// Not code, or too little of it to tell.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best guess at the language of an unhinted block, or `None` when it is not
|
||||||
|
/// confidently code. A wrong colour scheme is worse than none.
|
||||||
|
pub fn guess_language(code: &str) -> Option<&'static str> {
|
||||||
|
match detect(code) {
|
||||||
|
Detected::Sure(language) => Some(language),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect(code: &str) -> Detected {
|
||||||
|
let text = code.trim();
|
||||||
|
// Parsing is proof, at any length.
|
||||||
|
if (text.starts_with('{') || text.starts_with('['))
|
||||||
|
&& serde_json::from_str::<serde_json::Value>(text).is_ok()
|
||||||
|
{
|
||||||
|
return Detected::Sure("json");
|
||||||
|
}
|
||||||
|
if text.chars().count() < MIN_GUESS_CHARS && !text.starts_with("#!") {
|
||||||
|
return Detected::Unknown;
|
||||||
|
}
|
||||||
|
if let Some(language) = first_line_language(text) {
|
||||||
|
return Detected::Sure(language);
|
||||||
|
}
|
||||||
|
// A shell session is prompts interleaved with output; neither grammar fits.
|
||||||
|
if text.starts_with("$ ") || text.starts_with("> ") || text.starts_with("% ") {
|
||||||
|
return Detected::Unknown;
|
||||||
|
}
|
||||||
|
let sample = crate::html::truncate_utf8(text, MAX_GUESS_BYTES);
|
||||||
|
let detection = betlang::detect(sample);
|
||||||
|
let mut ranked = detection.top_languages().take(MAX_CANDIDATES);
|
||||||
|
let Some((score, top)) = ranked.next() else {
|
||||||
|
return Detected::Unknown;
|
||||||
|
};
|
||||||
|
// Prose, logs and program output all come out as Markdown.
|
||||||
|
let Some(top) = betlang_language(top) else {
|
||||||
|
return Detected::Unknown;
|
||||||
|
};
|
||||||
|
if score >= MIN_GUESS_SCORE {
|
||||||
|
return Detected::Sure(top);
|
||||||
|
}
|
||||||
|
let mut candidates = vec![top];
|
||||||
|
candidates.extend(
|
||||||
|
ranked
|
||||||
|
.filter(|(score, _)| *score >= MIN_CANDIDATE_SCORE)
|
||||||
|
.filter_map(|(_, language)| betlang_language(language)),
|
||||||
|
);
|
||||||
|
Detected::Maybe(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grammar for a betlang label, if we have one.
|
||||||
|
fn betlang_language(language: betlang::Language) -> Option<&'static str> {
|
||||||
|
let name = match language.slug() {
|
||||||
|
"markdown" => return None,
|
||||||
|
"cs" => "c-sharp",
|
||||||
|
"objectivec" => "objc",
|
||||||
|
"lisp" => "commonlisp",
|
||||||
|
"shell" => "bash",
|
||||||
|
"gemfile" | "gemspec" => "ruby",
|
||||||
|
"gradle" => "groovy",
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
supported(name).then_some(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Languages a block announces on its first line: shebangs and XML-ish preludes.
|
||||||
|
fn first_line_language(text: &str) -> Option<&'static str> {
|
||||||
|
let first = text.lines().next()?.trim().to_ascii_lowercase();
|
||||||
|
if let Some(shebang) = first.strip_prefix("#!") {
|
||||||
|
let interpreter = shebang
|
||||||
|
.split_whitespace()
|
||||||
|
.flat_map(|word| word.rsplit('/').next())
|
||||||
|
.find(|word| *word != "env" && !word.starts_with('-'))?;
|
||||||
|
let name = match interpreter.trim_end_matches(|c: char| c.is_ascii_digit() || c == '.') {
|
||||||
|
"sh" | "bash" | "dash" | "ksh" => "bash",
|
||||||
|
"zsh" => "zsh",
|
||||||
|
"fish" => "fish",
|
||||||
|
"python" => "python",
|
||||||
|
"node" | "deno" | "bun" => "javascript",
|
||||||
|
"ruby" => "ruby",
|
||||||
|
"perl" => "perl",
|
||||||
|
"php" => "php",
|
||||||
|
"lua" => "lua",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
return supported(name).then_some(name);
|
||||||
|
}
|
||||||
|
if first.starts_with("<?php") {
|
||||||
|
return Some("php");
|
||||||
|
}
|
||||||
|
if first.starts_with("<?xml") {
|
||||||
|
return Some("xml");
|
||||||
|
}
|
||||||
|
// Pages and the tags posts most often quote on their own.
|
||||||
|
if ["<!doctype html", "<html", "<script", "<style", "<template"]
|
||||||
|
.iter()
|
||||||
|
.any(|prefix| first.starts_with(prefix))
|
||||||
|
{
|
||||||
|
return Some("html");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One `<pre>` block found by [`highlight_blocks`].
|
||||||
|
struct Block {
|
||||||
|
/// Byte range of the opening tag, and where `</pre>` starts.
|
||||||
|
start: usize,
|
||||||
|
open_end: usize,
|
||||||
|
close: usize,
|
||||||
|
attrs: Vec<(String, String)>,
|
||||||
|
hint: Option<String>,
|
||||||
|
code: Option<String>,
|
||||||
|
detected: Detected,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highlight every `<pre>` block in sanitized markup.
|
||||||
|
///
|
||||||
|
/// A block's language is its `data-lang`, else what its content confidently
|
||||||
|
/// says ([`guess_language`]). A block whose content is only plausibly code
|
||||||
|
/// takes the article's prevailing language when that is one of its
|
||||||
|
/// candidates, so a post in Go does not alternate between highlighted and
|
||||||
|
/// plain listings. A highlighted block becomes `<pre><code>…spans…</code></pre>`;
|
||||||
|
/// any other block is copied through untouched apart from losing `data-lang`,
|
||||||
|
/// which is only for this step.
|
||||||
|
pub fn highlight_blocks(html: &str) -> String {
|
||||||
|
if !html.contains("<pre") {
|
||||||
|
return html.to_string();
|
||||||
|
}
|
||||||
|
let blocks = find_blocks(html);
|
||||||
|
let prevailing = prevailing_language(&blocks);
|
||||||
|
let mut highlighter: Option<Highlighter> = None;
|
||||||
|
let mut out = String::with_capacity(html.len() * 2);
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
for block in &blocks {
|
||||||
|
out.push_str(&html[cursor..block.start]);
|
||||||
|
let language = match (block.hint.as_deref(), &block.detected) {
|
||||||
|
(Some(PLAIN), _) => None,
|
||||||
|
(Some(hint), _) => Some(hint),
|
||||||
|
(None, Detected::Sure(language)) => Some(*language),
|
||||||
|
(None, Detected::Maybe(candidates)) => {
|
||||||
|
prevailing.filter(|language| candidates.contains(language))
|
||||||
|
}
|
||||||
|
(None, Detected::Unknown) => None,
|
||||||
|
};
|
||||||
|
let highlighted = match (language, &block.code) {
|
||||||
|
(Some(language), Some(code)) => highlighter
|
||||||
|
.get_or_insert_with(new_highlighter)
|
||||||
|
.highlight(language, code)
|
||||||
|
.ok()
|
||||||
|
.filter(|spans| spans.contains("<span")),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
match highlighted {
|
||||||
|
Some(spans) => {
|
||||||
|
out.push_str("<pre><code>");
|
||||||
|
out.push_str(&spans);
|
||||||
|
out.push_str("</code></pre>");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
out.push_str(&pre_without_lang(
|
||||||
|
&block.attrs,
|
||||||
|
block.hint.is_some(),
|
||||||
|
&html[block.start..block.open_end],
|
||||||
|
));
|
||||||
|
out.push_str(&html[block.open_end..block.close]);
|
||||||
|
out.push_str("</pre>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor = block.close + "</pre>".len();
|
||||||
|
}
|
||||||
|
out.push_str(&html[cursor..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every complete `<pre>` block, in order, with its hint and detection.
|
||||||
|
fn find_blocks(html: &str) -> Vec<Block> {
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
while let Some(rel) = html[cursor..].find("<pre") {
|
||||||
|
let start = cursor + rel;
|
||||||
|
let Some(open_end) = tag_end(html, start) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let inner = html[start..open_end]
|
||||||
|
.trim_start_matches('<')
|
||||||
|
.trim_end_matches('>');
|
||||||
|
if tag_name(inner) != "pre" {
|
||||||
|
cursor = open_end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(close_rel) = html[open_end..].find("</pre>") else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let close = open_end + close_rel;
|
||||||
|
let attrs = parse_attrs(inner);
|
||||||
|
let hint = attrs
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| name == LANG_ATTR)
|
||||||
|
.map(|(_, value)| value.clone());
|
||||||
|
let code = block_text(&html[open_end..close]);
|
||||||
|
let detected = match (&hint, &code) {
|
||||||
|
(None, Some(code)) => detect(code),
|
||||||
|
_ => Detected::Unknown,
|
||||||
|
};
|
||||||
|
blocks.push(Block {
|
||||||
|
start,
|
||||||
|
open_end,
|
||||||
|
close,
|
||||||
|
attrs,
|
||||||
|
hint,
|
||||||
|
code,
|
||||||
|
detected,
|
||||||
|
});
|
||||||
|
cursor = close + "</pre>".len();
|
||||||
|
}
|
||||||
|
blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The language most of an article's hinted and confidently detected blocks
|
||||||
|
/// share, earliest first on a tie.
|
||||||
|
fn prevailing_language(blocks: &[Block]) -> Option<&str> {
|
||||||
|
let mut counts: Vec<(&str, usize)> = Vec::new();
|
||||||
|
for block in blocks {
|
||||||
|
let language = match (block.hint.as_deref(), &block.detected) {
|
||||||
|
(Some(PLAIN), _) => continue,
|
||||||
|
(Some(hint), _) => hint,
|
||||||
|
(None, Detected::Sure(language)) => language,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
match counts.iter_mut().find(|(name, _)| *name == language) {
|
||||||
|
Some((_, count)) => *count += 1,
|
||||||
|
None => counts.push((language, 1)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counts
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.max_by_key(|(_, count)| *count)
|
||||||
|
.map(|(language, _)| *language)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `<pre>` opening tag minus `data-lang`, or `raw` when it had none.
|
||||||
|
fn pre_without_lang(attrs: &[(String, String)], had_lang: bool, raw: &str) -> String {
|
||||||
|
if !had_lang {
|
||||||
|
return raw.to_string();
|
||||||
|
}
|
||||||
|
let mut tag = String::from("<pre");
|
||||||
|
for (name, value) in attrs.iter().filter(|(name, _)| name != LANG_ATTR) {
|
||||||
|
tag.push_str(&format!(" {name}=\"{}\"", attr_escape(value)));
|
||||||
|
}
|
||||||
|
tag.push('>');
|
||||||
|
tag
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source text of a `<pre>` body: an optional wrapping `<code>` removed,
|
||||||
|
/// `<br>` as newlines, inline markup dropped, entities decoded, and the one
|
||||||
|
/// leading newline HTML ignores stripped.
|
||||||
|
///
|
||||||
|
/// `None` when the body holds block markup (a table, a list) — that is a
|
||||||
|
/// layout, not a listing, and is left alone.
|
||||||
|
fn block_text(body: &str) -> Option<String> {
|
||||||
|
let mut text = String::with_capacity(body.len());
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
while let Some(rel) = body[cursor..].find('<') {
|
||||||
|
let start = cursor + rel;
|
||||||
|
text.push_str(&body[cursor..start]);
|
||||||
|
let end = tag_end(body, start)?;
|
||||||
|
let inner = body[start..end]
|
||||||
|
.trim_start_matches('<')
|
||||||
|
.trim_end_matches('>');
|
||||||
|
match tag_name(inner).as_str() {
|
||||||
|
"br" => text.push('\n'),
|
||||||
|
"code" | "span" | "em" | "strong" | "a" | "b" | "i" | "u" | "s" | "mark" | "kbd"
|
||||||
|
| "samp" | "var" | "sub" | "sup" | "small" => {}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
text.push_str(&body[cursor..]);
|
||||||
|
let text = decode_entities(&text);
|
||||||
|
let text = text
|
||||||
|
.strip_prefix("\r\n")
|
||||||
|
.or_else(|| text.strip_prefix('\n'))
|
||||||
|
.unwrap_or(&text);
|
||||||
|
let text = text.trim_end_matches(['\n', '\r']);
|
||||||
|
(!text.trim().is_empty()).then(|| text.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_prefixed_bare_and_plain_hints() {
|
||||||
|
assert_eq!(normalize_hint("language-rust").as_deref(), Some("rust"));
|
||||||
|
assert_eq!(normalize_hint("lang-py").as_deref(), Some("python"));
|
||||||
|
assert_eq!(normalize_hint("highlight-source-go").as_deref(), Some("go"));
|
||||||
|
assert_eq!(normalize_hint("Python").as_deref(), Some("python"));
|
||||||
|
assert_eq!(normalize_hint("language-text").as_deref(), Some(PLAIN));
|
||||||
|
assert_eq!(normalize_hint("nohighlight").as_deref(), Some(PLAIN));
|
||||||
|
assert_eq!(normalize_hint("language-c").as_deref(), Some("c"));
|
||||||
|
// Bare one-letter classes and framework classes are not hints.
|
||||||
|
assert_eq!(normalize_hint("c"), None);
|
||||||
|
assert_eq!(normalize_hint("highlight"), None);
|
||||||
|
assert_eq!(normalize_hint("chroma"), None);
|
||||||
|
assert_eq!(normalize_hint("language-klingon"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn annotates_hints_from_pre_code_and_wrapper_divs() {
|
||||||
|
assert_eq!(
|
||||||
|
annotate_hints(r#"<pre class="chroma"><code class="language-go">x</code></pre>"#),
|
||||||
|
r#"<pre class="chroma" data-lang="go"><code class="language-go">x</code></pre>"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
annotate_hints(r#"<pre class="brush: py; gutter: false">x</pre>"#),
|
||||||
|
r#"<pre class="brush: py; gutter: false" data-lang="python">x</pre>"#
|
||||||
|
);
|
||||||
|
// Jekyll/Rouge: the hint is on a wrapper two levels up.
|
||||||
|
assert_eq!(
|
||||||
|
annotate_hints(
|
||||||
|
r#"<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code>x</code></pre></div></div><pre>y</pre>"#
|
||||||
|
),
|
||||||
|
r#"<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight" data-lang="ruby"><code>x</code></pre></div></div><pre>y</pre>"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
annotate_hints(r#"<pre data-code-block-lang="JavaScript">x</pre>"#),
|
||||||
|
r#"<pre data-lang="javascript">x</pre>"#
|
||||||
|
);
|
||||||
|
let plain = "<p>a</p><pre><code>no hint</code></pre>";
|
||||||
|
assert_eq!(annotate_hints(plain), plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_sanitizer_filter_only_keeps_normalized_values() {
|
||||||
|
assert_eq!(filter_lang_attr("rust").as_deref(), Some("rust"));
|
||||||
|
assert_eq!(filter_lang_attr("text").as_deref(), Some(PLAIN));
|
||||||
|
assert_eq!(filter_lang_attr("language-rust"), None);
|
||||||
|
// Hints are stored canonical, so an alias is not a stored value.
|
||||||
|
assert_eq!(filter_lang_attr("py"), None);
|
||||||
|
assert_eq!(filter_lang_attr("\" onload=\"x"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlights_hinted_blocks_with_flat_prefixed_spans() {
|
||||||
|
let out = highlight_blocks(
|
||||||
|
"<p>a</p><pre data-lang=\"rust\"><code>fn main() {\n let s = \"<&>\";\n}</code></pre><p>b</p>",
|
||||||
|
);
|
||||||
|
assert!(out.starts_with("<p>a</p><pre><code><span class=\"hl-keyword\">fn</span>"));
|
||||||
|
assert!(out.contains("<span class=\"hl-string\">"<&>"</span>"));
|
||||||
|
assert!(out.ends_with("</code></pre><p>b</p>"));
|
||||||
|
assert!(!out.contains("data-lang"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn br_line_breaks_and_inline_markup_become_plain_source() {
|
||||||
|
let out = highlight_blocks(
|
||||||
|
"<pre data-lang=\"python\"><code>def f():<br> <em>return</em> 1</code></pre>",
|
||||||
|
);
|
||||||
|
assert!(out.contains("<span class=\"hl-keyword\">def</span>"));
|
||||||
|
assert!(out.contains("\n <span class=\"hl-keyword\">return</span>"));
|
||||||
|
assert!(!out.contains("<em>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_and_unrecognized_blocks_are_left_alone() {
|
||||||
|
assert_eq!(
|
||||||
|
highlight_blocks("<pre data-lang=\"text\"><code>def f(): return 1</code></pre>"),
|
||||||
|
"<pre><code>def f(): return 1</code></pre>"
|
||||||
|
);
|
||||||
|
let short = "<pre><code>cargo build</code></pre>";
|
||||||
|
assert_eq!(highlight_blocks(short), short);
|
||||||
|
let prose = "<pre>Dear reader, this is a poem set in a preformatted block\nbecause the author liked the look of it.</pre>";
|
||||||
|
assert_eq!(highlight_blocks(prose), prose);
|
||||||
|
let session = "<pre><code>$ cargo build --release\n Compiling foo v0.1.0\n Finished release</code></pre>";
|
||||||
|
assert_eq!(highlight_blocks(session), session);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guesses_unhinted_code_by_content() {
|
||||||
|
assert_eq!(
|
||||||
|
guess_language(
|
||||||
|
"import os\n\ndef main():\n print(os.getcwd())\n\nif __name__ == '__main__':\n main()\n"
|
||||||
|
),
|
||||||
|
Some("python")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
guess_language(
|
||||||
|
"SELECT id, name FROM users WHERE created_at > NOW() - INTERVAL '1 day' ORDER BY id;"
|
||||||
|
),
|
||||||
|
Some("sql")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
guess_language("#!/usr/bin/env bash\nset -euo pipefail"),
|
||||||
|
Some("bash")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
guess_language("{\"a\": [1, 2, 3], \"b\": {\"c\": null}}"),
|
||||||
|
Some("json")
|
||||||
|
);
|
||||||
|
let out = highlight_blocks(
|
||||||
|
"<pre><code>#include <stdio.h>\n\nint main(void) {\n printf(\"hello\\n\");\n return 0;\n}</code></pre>",
|
||||||
|
);
|
||||||
|
assert!(out.contains("hl-"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uncertain_block_takes_the_articles_prevailing_language() {
|
||||||
|
// Both from go.dev/blog/range-functions, which marks no languages.
|
||||||
|
let sure = "<pre><code>// Add adds an element to a set.\nfunc (s *Set[E]) Add(v E) {\n s.m[v] = struct{}{}\n}\n\n// Contains reports whether an element is in a set.\nfunc (s *Set[E]) Contains(v E) bool {\n _, ok := s.m[v]\n return ok\n}\n</code></pre>";
|
||||||
|
let unsure = "<pre><code>func (s *Set[E]) Push(f func(E) bool) {\n for v := range s.m {\n if !f(v) {\n return\n }\n }\n}\n</code></pre>";
|
||||||
|
let text = |pre: &str| block_text(&pre[5..pre.len() - 6]).unwrap();
|
||||||
|
assert_eq!(guess_language(&text(sure)), Some("go"));
|
||||||
|
assert_eq!(guess_language(&text(unsure)), None);
|
||||||
|
assert!(matches!(detect(&text(unsure)), Detected::Maybe(c) if c.contains(&"go")));
|
||||||
|
|
||||||
|
let alone = highlight_blocks(unsure);
|
||||||
|
assert_eq!(alone, unsure);
|
||||||
|
let together = highlight_blocks(&format!("{sure}<p>and</p>{unsure}"));
|
||||||
|
assert_eq!(
|
||||||
|
together
|
||||||
|
.matches("<span class=\"hl-keyword\">func</span>")
|
||||||
|
.count(),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quoted_style_and_script_tags_are_html() {
|
||||||
|
assert_eq!(
|
||||||
|
guess_language("<style>\n* {\n box-sizing: border-box;\n}\n</style>"),
|
||||||
|
Some("html")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-6
@@ -32,6 +32,8 @@ pub const EXITED_BEFORE_START: &str = "unit exited before the job started; see t
|
|||||||
pub enum Job {
|
pub enum Job {
|
||||||
/// `generate` (today) or `generate-YYYY-MM-DD`.
|
/// `generate` (today) or `generate-YYYY-MM-DD`.
|
||||||
Generate { date: Option<Date> },
|
Generate { date: Option<Date> },
|
||||||
|
/// `rebuild-YYYY-MM-DD` → `rebuild --date`.
|
||||||
|
Rebuild { date: Date },
|
||||||
/// `dry-run` → `generate --dry-run`.
|
/// `dry-run` → `generate --dry-run`.
|
||||||
DryRun,
|
DryRun,
|
||||||
/// `profile-rebuild` → `profile rebuild`.
|
/// `profile-rebuild` → `profile rebuild`.
|
||||||
@@ -70,8 +72,9 @@ impl Job {
|
|||||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a catalogue name or the dated `generate-YYYY-MM-DD` form; anything
|
/// Parse a catalogue name or a dated `generate-YYYY-MM-DD` or
|
||||||
/// else (including `../x`, uppercase, unknown names) is `None`.
|
/// `rebuild-YYYY-MM-DD` form; anything else (including `../x`, uppercase,
|
||||||
|
/// unknown names) is `None`.
|
||||||
pub fn parse(name: &str) -> Option<Job> {
|
pub fn parse(name: &str) -> Option<Job> {
|
||||||
if !Self::valid_name(name) {
|
if !Self::valid_name(name) {
|
||||||
return None;
|
return None;
|
||||||
@@ -86,10 +89,17 @@ impl Job {
|
|||||||
"import-ratings" => Some(Job::ImportRatings),
|
"import-ratings" => Some(Job::ImportRatings),
|
||||||
"interests-categorize" => Some(Job::InterestsCategorize),
|
"interests-categorize" => Some(Job::InterestsCategorize),
|
||||||
_ => {
|
_ => {
|
||||||
let date = name.strip_prefix("generate-")?;
|
let (rebuild, date) = match name.strip_prefix("generate-") {
|
||||||
|
Some(date) => (false, date),
|
||||||
|
None => (true, name.strip_prefix("rebuild-")?),
|
||||||
|
};
|
||||||
// Exactly `YYYY-MM-DD`; the round trip rejects `2026-9-3`.
|
// Exactly `YYYY-MM-DD`; the round trip rejects `2026-9-3`.
|
||||||
let parsed: Date = date.parse().ok()?;
|
let parsed: Date = date.parse().ok()?;
|
||||||
(parsed.to_string() == date).then_some(Job::Generate { date: Some(parsed) })
|
(parsed.to_string() == date).then_some(if rebuild {
|
||||||
|
Job::Rebuild { date: parsed }
|
||||||
|
} else {
|
||||||
|
Job::Generate { date: Some(parsed) }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,6 +108,7 @@ impl Job {
|
|||||||
match self {
|
match self {
|
||||||
Job::Generate { date: None } => "generate".into(),
|
Job::Generate { date: None } => "generate".into(),
|
||||||
Job::Generate { date: Some(date) } => format!("generate-{date}"),
|
Job::Generate { date: Some(date) } => format!("generate-{date}"),
|
||||||
|
Job::Rebuild { date } => format!("rebuild-{date}"),
|
||||||
Job::DryRun => "dry-run".into(),
|
Job::DryRun => "dry-run".into(),
|
||||||
Job::ProfileRebuild => "profile-rebuild".into(),
|
Job::ProfileRebuild => "profile-rebuild".into(),
|
||||||
Job::FeaturesBackfill => "features-backfill".into(),
|
Job::FeaturesBackfill => "features-backfill".into(),
|
||||||
@@ -121,6 +132,9 @@ impl Job {
|
|||||||
Job::Generate { date: Some(_) } => {
|
Job::Generate { date: Some(_) } => {
|
||||||
"Build and publish the issue of a given date, republishing it if it exists."
|
"Build and publish the issue of a given date, republishing it if it exists."
|
||||||
}
|
}
|
||||||
|
Job::Rebuild { .. } => {
|
||||||
|
"Re-render and republish a published issue's EPUBs from its stored content, without curating again."
|
||||||
|
}
|
||||||
Job::DryRun => "Run the whole pipeline but publish nothing and record no issue.",
|
Job::DryRun => "Run the whole pipeline but publish nothing and record no issue.",
|
||||||
Job::ProfileRebuild => {
|
Job::ProfileRebuild => {
|
||||||
"Regenerate the learned taste adjustments from ratings with the editor model."
|
"Regenerate the learned taste adjustments from ratings with the editor model."
|
||||||
@@ -144,6 +158,7 @@ impl Job {
|
|||||||
pub fn takes_lock(&self) -> Option<&'static str> {
|
pub fn takes_lock(&self) -> Option<&'static str> {
|
||||||
match self {
|
match self {
|
||||||
Job::Generate { .. } | Job::DryRun => Some("generate"),
|
Job::Generate { .. } | Job::DryRun => Some("generate"),
|
||||||
|
Job::Rebuild { .. } => Some("rebuild"),
|
||||||
Job::ProfileRebuild => Some("profile rebuild"),
|
Job::ProfileRebuild => Some("profile rebuild"),
|
||||||
Job::FeaturesBackfill => Some("features backfill"),
|
Job::FeaturesBackfill => Some("features backfill"),
|
||||||
Job::BackfillSocial => Some("backfill-social"),
|
Job::BackfillSocial => Some("backfill-social"),
|
||||||
@@ -151,9 +166,9 @@ impl Job {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Needs a confirmation dialog: `generate` republishes an issue.
|
/// Needs a confirmation dialog: `generate` and `rebuild` republish an issue.
|
||||||
pub fn dangerous(&self) -> bool {
|
pub fn dangerous(&self) -> bool {
|
||||||
matches!(self, Job::Generate { .. })
|
matches!(self, Job::Generate { .. } | Job::Rebuild { .. })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,6 +496,24 @@ mod tests {
|
|||||||
assert!(!Job::parse("dry-run").unwrap().dangerous());
|
assert!(!Job::parse("dry-run").unwrap().dangerous());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_accepts_the_dated_rebuild_form() {
|
||||||
|
let job = Job::parse("rebuild-2026-10-01").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
job,
|
||||||
|
Job::Rebuild {
|
||||||
|
date: "2026-10-01".parse().unwrap()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(job.name(), "rebuild-2026-10-01");
|
||||||
|
assert_eq!(job.unit(), "daily-epub-job@rebuild-2026-10-01.service");
|
||||||
|
assert_eq!(job.takes_lock(), Some("rebuild"));
|
||||||
|
assert!(job.dangerous());
|
||||||
|
for bad in ["rebuild", "rebuild-", "rebuild-2026-10-1", "rebuild-today"] {
|
||||||
|
assert_eq!(Job::parse(bad), None, "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_rejects_traversal_uppercase_and_unknown_names() {
|
fn parse_rejects_traversal_uppercase_and_unknown_names() {
|
||||||
for name in [
|
for name in [
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub mod dedupe;
|
|||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod epub;
|
pub mod epub;
|
||||||
pub mod extract;
|
pub mod extract;
|
||||||
|
pub mod highlight;
|
||||||
pub mod html;
|
pub mod html;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod images;
|
pub mod images;
|
||||||
@@ -34,6 +35,7 @@ pub mod miniflux;
|
|||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
pub mod publish;
|
pub mod publish;
|
||||||
pub mod rate;
|
pub mod rate;
|
||||||
|
pub mod related;
|
||||||
pub mod report;
|
pub mod report;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod social;
|
pub mod social;
|
||||||
|
|||||||
+84
-9
@@ -36,6 +36,8 @@ struct Cli {
|
|||||||
enum Command {
|
enum Command {
|
||||||
/// Build (and publish) one issue.
|
/// Build (and publish) one issue.
|
||||||
Generate(GenerateArgs),
|
Generate(GenerateArgs),
|
||||||
|
/// Rebuild and republish a published issue's EPUBs from its stored content.
|
||||||
|
Rebuild(RebuildArgs),
|
||||||
/// Run the rating endpoints, the OPDS catalog and downloads.
|
/// Run the rating endpoints, the OPDS catalog and downloads.
|
||||||
Serve,
|
Serve,
|
||||||
/// Taste-profile maintenance.
|
/// Taste-profile maintenance.
|
||||||
@@ -159,6 +161,13 @@ enum ConfigCommand {
|
|||||||
Check,
|
Check,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
struct RebuildArgs {
|
||||||
|
/// Date of the published issue to rebuild.
|
||||||
|
#[arg(long, value_name = "YYYY-MM-DD")]
|
||||||
|
date: jiff::civil::Date,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, clap::Args)]
|
#[derive(Debug, clap::Args)]
|
||||||
struct GenerateArgs {
|
struct GenerateArgs {
|
||||||
/// Issue date in the configured timezone (defaults to today).
|
/// Issue date in the configured timezone (defaults to today).
|
||||||
@@ -330,6 +339,15 @@ enum FeaturesCommand {
|
|||||||
Backfill(BackfillArgs),
|
Backfill(BackfillArgs),
|
||||||
/// Drop stale embeddings, old candidate telemetry and old assessments per the retention config.
|
/// Drop stale embeddings, old candidate telemetry and old assessments per the retention config.
|
||||||
Prune,
|
Prune,
|
||||||
|
/// Recompute the related articles under each issue's web articles.
|
||||||
|
Related(RelatedArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
struct RelatedArgs {
|
||||||
|
/// Only this issue (YYYY-MM-DD); every issue when omitted.
|
||||||
|
#[arg(long)]
|
||||||
|
date: Option<jiff::civil::Date>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, clap::Args)]
|
#[derive(Debug, clap::Args)]
|
||||||
@@ -382,6 +400,17 @@ async fn main() -> Result<()> {
|
|||||||
let outcome = pipeline::generate(&config, &db, &GenerateOptions::from(&args)).await?;
|
let outcome = pipeline::generate(&config, &db, &GenerateOptions::from(&args)).await?;
|
||||||
print_outcome(&outcome);
|
print_outcome(&outcome);
|
||||||
}
|
}
|
||||||
|
Command::Rebuild(args) => {
|
||||||
|
// No migration: this only re-renders what an existing schema holds.
|
||||||
|
let db = Db::open(&config.database_path).await?;
|
||||||
|
let published = pipeline::rebuild(&config, &db, args.date).await?;
|
||||||
|
for artifact in &published.epubs {
|
||||||
|
println!("{:?}: {}", artifact.edition, artifact.path.display());
|
||||||
|
}
|
||||||
|
if let Some(xtc) = &published.xtc {
|
||||||
|
println!("XTC: {}", xtc.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
Command::Serve => {
|
Command::Serve => {
|
||||||
let db = Db::open_and_migrate(&config.database_path).await?;
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
||||||
let config_path = Config::resolve_path(cli.config.as_deref());
|
let config_path = Config::resolve_path(cli.config.as_deref());
|
||||||
@@ -451,6 +480,14 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
.description()
|
.description()
|
||||||
);
|
);
|
||||||
|
eprintln!(
|
||||||
|
" {:<20} {}",
|
||||||
|
"rebuild-YYYY-MM-DD",
|
||||||
|
jobs::Job::Rebuild {
|
||||||
|
date: jiff::civil::Date::default()
|
||||||
|
}
|
||||||
|
.description()
|
||||||
|
);
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
};
|
};
|
||||||
let db = Db::open_and_migrate(&config.database_path).await?;
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
||||||
@@ -461,12 +498,13 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The commands that write the database and provider budgets and so hold the
|
/// The commands that write the database and provider budgets and so hold the
|
||||||
/// run lock (§5): `generate`, `profile rebuild`, `features backfill`,
|
/// run lock (§5): `generate`, `rebuild`, `profile rebuild`, `features backfill`,
|
||||||
/// `backfill-social`, `interests backfill`, and a `job run` of any of them.
|
/// `backfill-social`, `interests backfill`, and a `job run` of any of them.
|
||||||
/// Everything else is read-only or its own writer.
|
/// Everything else is read-only or its own writer.
|
||||||
fn lock_holder(command: &Command) -> Option<&'static str> {
|
fn lock_holder(command: &Command) -> Option<&'static str> {
|
||||||
match command {
|
match command {
|
||||||
Command::Generate(_) => Some("generate"),
|
Command::Generate(_) => Some("generate"),
|
||||||
|
Command::Rebuild(_) => Some("rebuild"),
|
||||||
// Writes the same tables the discovery stage of a run writes.
|
// Writes the same tables the discovery stage of a run writes.
|
||||||
Command::Feeds(FeedsCommand::Discover(_)) => Some("feeds discover"),
|
Command::Feeds(FeedsCommand::Discover(_)) => Some("feeds discover"),
|
||||||
Command::Profile(ProfileCommand::Rebuild) => Some("profile rebuild"),
|
Command::Profile(ProfileCommand::Rebuild) => Some("profile rebuild"),
|
||||||
@@ -483,6 +521,7 @@ fn lock_holder(command: &Command) -> Option<&'static str> {
|
|||||||
| Command::Explain(_)
|
| Command::Explain(_)
|
||||||
| Command::Stats(_)
|
| Command::Stats(_)
|
||||||
| Command::Features(FeaturesCommand::Prune)
|
| Command::Features(FeaturesCommand::Prune)
|
||||||
|
| Command::Features(FeaturesCommand::Related(_))
|
||||||
| Command::Db(_)
|
| Command::Db(_)
|
||||||
| Command::Config(_)
|
| Command::Config(_)
|
||||||
| Command::Users(_)
|
| Command::Users(_)
|
||||||
@@ -1033,7 +1072,7 @@ async fn cmd_explain(db: &Db, args: ExplainArgs) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `features backfill` / `features prune`; returns the final summary line
|
/// `features backfill` / `features prune` / `features related`; returns the final summary line
|
||||||
/// (progress lines are printed as they happen).
|
/// (progress lines are printed as they happen).
|
||||||
async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Result<String> {
|
async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Result<String> {
|
||||||
match command {
|
match command {
|
||||||
@@ -1091,13 +1130,25 @@ async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Res
|
|||||||
jiff::Timestamp::now(),
|
jiff::Timestamp::now(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let embeddings = if ranking.embedding_retention_days <= 0 {
|
||||||
|
"kept every embedding (retention 0)".to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"pruned {} embeddings older than {} days",
|
||||||
|
pruned.embeddings, ranking.embedding_retention_days
|
||||||
|
)
|
||||||
|
};
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"pruned {} embeddings older than {} days, {} candidate rows and {} assessments older than {} days",
|
"{embeddings}, {} candidate rows and {} assessments older than {} days",
|
||||||
pruned.embeddings,
|
pruned.telemetry, pruned.assessments, ranking.telemetry_retention_days
|
||||||
ranking.embedding_retention_days,
|
))
|
||||||
pruned.telemetry,
|
}
|
||||||
pruned.assessments,
|
FeaturesCommand::Related(args) => {
|
||||||
ranking.telemetry_retention_days
|
let dates: Vec<_> = args.date.into_iter().collect();
|
||||||
|
let refreshed = daily_epub::related::refresh(db, &config.voyage, &dates).await?;
|
||||||
|
Ok(format!(
|
||||||
|
"stored {} related links across {} issues",
|
||||||
|
refreshed.links, refreshed.issues
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1161,6 +1212,21 @@ async fn run_job(config: &Config, db: &Db, job: &jobs::Job) -> Result<(String, O
|
|||||||
jobs::Job::Generate { date } => {
|
jobs::Job::Generate { date } => {
|
||||||
generate_job(config, db, date.map(|date| date.to_string()), false).await
|
generate_job(config, db, date.map(|date| date.to_string()), false).await
|
||||||
}
|
}
|
||||||
|
jobs::Job::Rebuild { date } => {
|
||||||
|
let published = pipeline::rebuild(config, db, *date).await?;
|
||||||
|
let xtc = if published.xtc.is_some() {
|
||||||
|
" and the XTC"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
Ok((
|
||||||
|
format!(
|
||||||
|
"rebuilt the {date} issue: {} EPUBs{xtc}",
|
||||||
|
published.epubs.len()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
}
|
||||||
jobs::Job::DryRun => generate_job(config, db, None, true).await,
|
jobs::Job::DryRun => generate_job(config, db, None, true).await,
|
||||||
jobs::Job::ProfileRebuild => Ok((cmd_profile_rebuild(config, db).await?, None)),
|
jobs::Job::ProfileRebuild => Ok((cmd_profile_rebuild(config, db).await?, None)),
|
||||||
jobs::Job::FeaturesBackfill => {
|
jobs::Job::FeaturesBackfill => {
|
||||||
@@ -1450,7 +1516,7 @@ mod tests {
|
|||||||
row.message
|
row.message
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.starts_with("pruned 0 embeddings"),
|
.starts_with("kept every embedding (retention 0)"),
|
||||||
"{row:?}"
|
"{row:?}"
|
||||||
);
|
);
|
||||||
assert_eq!(row.run_id, None);
|
assert_eq!(row.run_id, None);
|
||||||
@@ -1640,6 +1706,15 @@ mod tests {
|
|||||||
.command,
|
.command,
|
||||||
Command::Features(FeaturesCommand::Prune)
|
Command::Features(FeaturesCommand::Prune)
|
||||||
));
|
));
|
||||||
|
match Cli::try_parse_from(["daily-epub", "features", "related", "--date", "2026-09-01"])
|
||||||
|
.unwrap()
|
||||||
|
.command
|
||||||
|
{
|
||||||
|
Command::Features(FeaturesCommand::Related(args)) => {
|
||||||
|
assert_eq!(args.date, Some("2026-09-01".parse().unwrap()));
|
||||||
|
}
|
||||||
|
other => panic!("expected features related, got {other:?}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+140
@@ -268,6 +268,7 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul
|
|||||||
db.finish_run(run_id, &report).await?;
|
db.finish_run(run_id, &report).await?;
|
||||||
if stages.published.is_some() {
|
if stages.published.is_some() {
|
||||||
prune_retention(config, db).await;
|
prune_retention(config, db).await;
|
||||||
|
refresh_related(config, db, date).await;
|
||||||
}
|
}
|
||||||
// The issue row is written before the report is costed, so stamp the finished
|
// The issue row is written before the report is costed, so stamp the finished
|
||||||
// report onto it now (the paths are preserved by `COALESCE`, §3.13).
|
// report onto it now (the paths are preserved by `COALESCE`, §3.13).
|
||||||
@@ -321,6 +322,15 @@ async fn prune_retention(config: &Config, db: &Db) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store the issue's related-article links (best effort: a failure only
|
||||||
|
/// leaves the web article pages without them until `features related`).
|
||||||
|
async fn refresh_related(config: &Config, db: &Db, date: Date) {
|
||||||
|
match crate::related::refresh(db, &config.voyage, &[date]).await {
|
||||||
|
Ok(refreshed) => tracing::info!(links = refreshed.links, "related articles stored"),
|
||||||
|
Err(error) => tracing::warn!(%error, "related articles failed; continuing"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What [`run_stages`] hands back; [`generate`] pairs it with the costed report.
|
/// What [`run_stages`] hands back; [`generate`] pairs it with the costed report.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct StageOutput {
|
struct StageOutput {
|
||||||
@@ -1095,6 +1105,51 @@ async fn persist_articles(db: &Db, articles: &mut [Article]) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rebuild and republish a published issue's EPUBs (and XTC) from its stored
|
||||||
|
/// snapshot, for when rendering changed after the issue went out.
|
||||||
|
///
|
||||||
|
/// The content is exactly the issue's: the `issues.issue_json` snapshot with
|
||||||
|
/// each article's body restored from its row (the snapshot drops bodies).
|
||||||
|
/// Unlike the web reader it does not refresh anything else from the live rows,
|
||||||
|
/// so social lines and metadata stay as published. Nothing is curated or
|
||||||
|
/// fetched except the images, which are downloaded again as in a run.
|
||||||
|
pub async fn rebuild(config: &Config, db: &Db, date: Date) -> Result<Published> {
|
||||||
|
let row = db
|
||||||
|
.issue_by_date(date)
|
||||||
|
.await?
|
||||||
|
.with_context(|| format!("no issue for {date}"))?;
|
||||||
|
let raw = row
|
||||||
|
.issue_json
|
||||||
|
.with_context(|| format!("the {date} issue predates issue snapshots"))?;
|
||||||
|
let mut issue: Issue = serde_json::from_str(&raw).context("decoding issues.issue_json")?;
|
||||||
|
let ids: Vec<ArticleId> = issue.lineup.picks.iter().map(|p| p.article.id).collect();
|
||||||
|
let mut articles = db.get_articles(&ids).await?;
|
||||||
|
for pick in &mut issue.lineup.picks {
|
||||||
|
let article = articles
|
||||||
|
.remove(&pick.article.id)
|
||||||
|
.with_context(|| format!("article {} is gone", pick.article.id))?;
|
||||||
|
pick.article.content_html = article.content_html;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (artifacts, _) = epub::build_all(&issue, config, &config.out_dir)
|
||||||
|
.await
|
||||||
|
.context("building the EPUB editions")?;
|
||||||
|
let x4 = artifacts.iter().find(|a| a.edition == Edition::X4);
|
||||||
|
let xtc = match x4 {
|
||||||
|
Some(artifact) if config.xtc.enabled => {
|
||||||
|
Some(epub::x4::convert(&config.xtc, &artifact.path, &config.out_dir).await?)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let published = publish::publish_issue(config, &issue, &artifacts, xtc.as_deref())
|
||||||
|
.await
|
||||||
|
.context("publishing the issue")?;
|
||||||
|
record_issue(db, &issue, &published)
|
||||||
|
.await
|
||||||
|
.context("recording the issue")?;
|
||||||
|
Ok(published)
|
||||||
|
}
|
||||||
|
|
||||||
/// Write the `issues` row and replace `issue_articles` for the date (notes §12).
|
/// Write the `issues` row and replace `issue_articles` for the date (notes §12).
|
||||||
async fn record_issue(db: &Db, issue: &Issue, published: &Published) -> Result<()> {
|
async fn record_issue(db: &Db, issue: &Issue, published: &Published) -> Result<()> {
|
||||||
let path_for = |edition: Edition| {
|
let path_for = |edition: Edition| {
|
||||||
@@ -1346,6 +1401,91 @@ fn elapsed_ms(since: Timestamp) -> i64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Read one entry of a zip archive as text.
|
||||||
|
fn zip_entry(path: &std::path::Path, name: &str) -> String {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut archive = zip::ZipArchive::new(std::fs::File::open(path).unwrap()).unwrap();
|
||||||
|
let mut text = String::new();
|
||||||
|
archive
|
||||||
|
.by_name(name)
|
||||||
|
.unwrap()
|
||||||
|
.read_to_string(&mut text)
|
||||||
|
.unwrap();
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rebuild_republishes_from_the_snapshot_without_touching_it() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut issue = crate::epub::fixtures::issue();
|
||||||
|
for pick in &mut issue.lineup.picks {
|
||||||
|
// No images: the rebuild must not need the network here.
|
||||||
|
pick.article.content_html =
|
||||||
|
"<p>Before.</p><pre data-lang=\"rust\"><code>fn main() {}</code></pre>".into();
|
||||||
|
pick.article.image_count = 0;
|
||||||
|
let article = &pick.article;
|
||||||
|
db.upsert_entry(&crate::types::Entry {
|
||||||
|
id: article.best_entry_id,
|
||||||
|
feed_id: article.feed_id,
|
||||||
|
feed_title: Some(article.feed_title.clone()),
|
||||||
|
category: article.category.clone(),
|
||||||
|
title: article.title.clone(),
|
||||||
|
url: article.url.clone(),
|
||||||
|
canonical_url: Some(article.canonical_url.clone()),
|
||||||
|
author: article.author.clone(),
|
||||||
|
published_at: article.published_at,
|
||||||
|
comments_url: article.comments_url.clone(),
|
||||||
|
raw_content: article.content_html.clone(),
|
||||||
|
fetched_at: article.first_seen,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
pick.article.id = db.upsert_article(article).await.unwrap();
|
||||||
|
}
|
||||||
|
let mut snapshot = issue.clone();
|
||||||
|
for pick in &mut snapshot.lineup.picks {
|
||||||
|
pick.article.content_html.clear();
|
||||||
|
}
|
||||||
|
let issue_json = serde_json::to_string(&snapshot).unwrap();
|
||||||
|
db.upsert_issue(
|
||||||
|
issue.meta.date,
|
||||||
|
issue.meta.issue_number,
|
||||||
|
issue.meta.generated_at,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(&issue.editorial.front_page_html),
|
||||||
|
None,
|
||||||
|
Some(&issue_json),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut config = Config {
|
||||||
|
out_dir: dir.path().join("out"),
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
config.publish.epub_dir = dir.path().join("epubs");
|
||||||
|
config.publish.xtc_dir = dir.path().join("xtc");
|
||||||
|
config.xtc.enabled = false;
|
||||||
|
let published = rebuild(&config, &db, issue.meta.date).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(published.epubs.len(), 2);
|
||||||
|
let row = db.issue_by_date(issue.meta.date).await.unwrap().unwrap();
|
||||||
|
assert_eq!(row.issue_json.as_deref(), Some(issue_json.as_str()));
|
||||||
|
let standard = &published.epubs[0];
|
||||||
|
assert_eq!(
|
||||||
|
row.epub_path.as_deref(),
|
||||||
|
Some(standard.path.to_str().unwrap())
|
||||||
|
);
|
||||||
|
let chapter = zip_entry(&standard.path, "OEBPS/art-1001.xhtml");
|
||||||
|
assert!(chapter.contains("<p>Before.</p>"), "body restored");
|
||||||
|
assert!(chapter.contains("<span class=\"hl-keyword\">fn</span>"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_date_matches_the_masthead_format() {
|
fn display_date_matches_the_masthead_format() {
|
||||||
let date: Date = "2026-08-15".parse().unwrap();
|
let date: Date = "2026-08-15".parse().unwrap();
|
||||||
|
|||||||
+446
@@ -0,0 +1,446 @@
|
|||||||
|
//! Related articles under each web issue article.
|
||||||
|
//!
|
||||||
|
//! For every pick, the nearest embedded articles across the whole archive
|
||||||
|
//! (`article_embeddings`, kept forever by default). Finding them is a scan of
|
||||||
|
//! every cached vector, so it runs once when an issue is published and once
|
||||||
|
//! per issue from `features related`, and the result lives in `issue_related`;
|
||||||
|
//! reader pages only read that table.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use jiff::civil::Date;
|
||||||
|
use sqlx::Row as _;
|
||||||
|
|
||||||
|
use crate::config::VoyageConfig;
|
||||||
|
use crate::curate::embedding::{decode_blob, dot, load_article_embeddings};
|
||||||
|
use crate::db::Db;
|
||||||
|
use crate::types::ArticleId;
|
||||||
|
|
||||||
|
/// Links shown under each article.
|
||||||
|
const PER_ARTICLE: usize = 5;
|
||||||
|
/// Below this cosine a neighbour is noise, not a related article (the same
|
||||||
|
/// cut the curation prompts use for "closest rated").
|
||||||
|
const MIN_COSINE: f64 = 0.55;
|
||||||
|
/// Embedding rows decoded per statement, so memory stays flat as the archive grows.
|
||||||
|
const SCAN_PAGE: i64 = 1000;
|
||||||
|
/// Candidates kept per pick between pages: enough headroom for the
|
||||||
|
/// same-story filter at the end to still fill [`PER_ARTICLE`].
|
||||||
|
const KEEP: usize = PER_ARTICLE * 4;
|
||||||
|
/// Share of title words two articles from one site must have in common to be
|
||||||
|
/// one story re-published under a new URL ("…after AI accusations" and
|
||||||
|
/// "…after AI allegations").
|
||||||
|
const SAME_SITE_TITLE_OVERLAP: f64 = 0.8;
|
||||||
|
|
||||||
|
/// What [`refresh`] wrote.
|
||||||
|
#[derive(Debug, Default, PartialEq)]
|
||||||
|
pub struct Refreshed {
|
||||||
|
pub issues: usize,
|
||||||
|
pub links: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recompute `issue_related` for the given issues (every issue when `dates` is
|
||||||
|
/// empty) in one pass over the embedding cache.
|
||||||
|
///
|
||||||
|
/// Excluded: the pick itself, the rest of its issue, articles that only have a
|
||||||
|
/// feed excerpt (their page would be near-empty), and articles that are the
|
||||||
|
/// pick's or a closer link's story again (see [`Story::same_as`]).
|
||||||
|
pub async fn refresh(db: &Db, voyage: &VoyageConfig, dates: &[Date]) -> Result<Refreshed> {
|
||||||
|
let dates = if dates.is_empty() {
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT date FROM issues ORDER BY date")
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|raw| raw.parse().ok())
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
dates.to_vec()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every pick of every requested issue, with its story key for de-duplication.
|
||||||
|
let mut groups: Vec<Group> = Vec::with_capacity(dates.len());
|
||||||
|
for date in &dates {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT ia.article_id, a.title, a.canonical_url FROM issue_articles ia
|
||||||
|
JOIN articles a ON a.id = ia.article_id
|
||||||
|
WHERE ia.issue_date = ? ORDER BY ia.section, ia.position",
|
||||||
|
)
|
||||||
|
.bind(date.to_string())
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?;
|
||||||
|
groups.push(Group {
|
||||||
|
date: *date,
|
||||||
|
members: rows.iter().map(|row| row.get("article_id")).collect(),
|
||||||
|
picks: rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| Target {
|
||||||
|
article_id: row.get("article_id"),
|
||||||
|
story: Story::from_row(row),
|
||||||
|
vector: Vec::new(),
|
||||||
|
best: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let ids: Vec<ArticleId> = groups
|
||||||
|
.iter()
|
||||||
|
.flat_map(|group| group.picks.iter().map(|pick| pick.article_id))
|
||||||
|
.collect();
|
||||||
|
let mut vectors = load_article_embeddings(db, voyage, &ids).await?;
|
||||||
|
for group in &mut groups {
|
||||||
|
// A pick without a cached vector simply gets no related list.
|
||||||
|
group
|
||||||
|
.picks
|
||||||
|
.retain_mut(|pick| match vectors.remove(&pick.article_id) {
|
||||||
|
Some(vector) => {
|
||||||
|
pick.vector = vector;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut after = i64::MIN;
|
||||||
|
loop {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT ae.article_id, ae.embedding, a.title, a.canonical_url
|
||||||
|
FROM article_embeddings ae JOIN articles a ON a.id = ae.article_id
|
||||||
|
WHERE ae.model = ? AND ae.dimension = ? AND a.excerpt_only = 0
|
||||||
|
AND ae.article_id > ?
|
||||||
|
ORDER BY ae.article_id LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(&voyage.model)
|
||||||
|
.bind(voyage.output_dimension as i64)
|
||||||
|
.bind(after)
|
||||||
|
.bind(SCAN_PAGE)
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?;
|
||||||
|
let Some(last) = rows.last() else { break };
|
||||||
|
after = last.get("article_id");
|
||||||
|
for row in &rows {
|
||||||
|
let candidate: ArticleId = row.get("article_id");
|
||||||
|
let vector = match decode_blob(
|
||||||
|
&row.get::<Vec<u8>, _>("embedding"),
|
||||||
|
voyage.output_dimension,
|
||||||
|
) {
|
||||||
|
Ok(vector) => vector,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(article_id = candidate, %error, "ignoring a malformed embedding");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let story = Story::from_row(row);
|
||||||
|
for group in &mut groups {
|
||||||
|
if group.members.contains(&candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for pick in &mut group.picks {
|
||||||
|
let cosine = dot(&pick.vector, &vector)?;
|
||||||
|
if cosine.is_finite() && cosine >= MIN_COSINE {
|
||||||
|
pick.best.push((candidate, cosine, story.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for group in &mut groups {
|
||||||
|
for pick in &mut group.picks {
|
||||||
|
// Highest cosine first; ties by id so reruns write the same rows.
|
||||||
|
pick.best.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.1
|
||||||
|
.total_cmp(&left.1)
|
||||||
|
.then_with(|| left.0.cmp(&right.0))
|
||||||
|
});
|
||||||
|
pick.best.truncate(KEEP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut refreshed = Refreshed::default();
|
||||||
|
let mut tx = db.pool().begin().await?;
|
||||||
|
for group in &groups {
|
||||||
|
let date = group.date.to_string();
|
||||||
|
sqlx::query("DELETE FROM issue_related WHERE issue_date = ?")
|
||||||
|
.bind(&date)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
for pick in &group.picks {
|
||||||
|
let mut seen = vec![&pick.story];
|
||||||
|
let mut chosen = Vec::new();
|
||||||
|
for (id, cosine, story) in &pick.best {
|
||||||
|
if chosen.len() == PER_ARTICLE {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !seen.iter().any(|earlier| earlier.same_as(story)) {
|
||||||
|
seen.push(story);
|
||||||
|
chosen.push((id, cosine));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (position, (related_id, cosine)) in chosen.into_iter().enumerate() {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO issue_related (issue_date, article_id, position, related_id, cosine)
|
||||||
|
VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&date)
|
||||||
|
.bind(pick.article_id)
|
||||||
|
.bind(position as i64)
|
||||||
|
.bind(related_id)
|
||||||
|
.bind(cosine)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
refreshed.links += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshed.issues += 1;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(refreshed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored related links for one pick, closest first.
|
||||||
|
pub async fn for_article(db: &Db, date: Date, article_id: ArticleId) -> Result<Vec<ArticleId>> {
|
||||||
|
Ok(sqlx::query_scalar(
|
||||||
|
"SELECT related_id FROM issue_related
|
||||||
|
WHERE issue_date = ? AND article_id = ? ORDER BY position",
|
||||||
|
)
|
||||||
|
.bind(date.to_string())
|
||||||
|
.bind(article_id)
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recent issue each of `ids` was published in, for linking a related
|
||||||
|
/// article to its issue page rather than the standalone one.
|
||||||
|
pub async fn latest_issues(db: &Db, ids: &[ArticleId]) -> Result<HashMap<ArticleId, Date>> {
|
||||||
|
let mut issues = HashMap::new();
|
||||||
|
for id in ids {
|
||||||
|
let date: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT MAX(issue_date) FROM issue_articles WHERE article_id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await?;
|
||||||
|
if let Some(date) = date.and_then(|raw| raw.parse().ok()) {
|
||||||
|
issues.insert(*id, date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Group {
|
||||||
|
date: Date,
|
||||||
|
/// Every pick of the issue, embedded or not: none of them is "related".
|
||||||
|
members: HashSet<ArticleId>,
|
||||||
|
picks: Vec<Target>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Target {
|
||||||
|
article_id: ArticleId,
|
||||||
|
story: Story,
|
||||||
|
vector: Vec<f32>,
|
||||||
|
/// (candidate, cosine, story), closest first after each scan page.
|
||||||
|
best: Vec<(ArticleId, f64, Story)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What decides whether two articles are one story told twice. The ingest
|
||||||
|
/// dedupe only merges within one run, so a story re-published on another day
|
||||||
|
/// under a new URL reaches the archive as a second article.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Story {
|
||||||
|
/// Canonical URL host without `www.`.
|
||||||
|
site: String,
|
||||||
|
/// Lowercased title words.
|
||||||
|
words: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Story {
|
||||||
|
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Self {
|
||||||
|
// Re-canonicalized so legacy rows still wrapped in a Scour redirect
|
||||||
|
// count as the outlet's site, not Scour's.
|
||||||
|
let raw: String = row.get("canonical_url");
|
||||||
|
let site = crate::dedupe::canonical_url(&raw)
|
||||||
|
.and_then(|canonical| url::Url::parse(&canonical).ok())
|
||||||
|
.and_then(|url| {
|
||||||
|
url.host_str()
|
||||||
|
.map(|host| host.trim_start_matches("www.").to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let words = row
|
||||||
|
.get::<Option<String>, _>("title")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split(|c: char| !c.is_alphanumeric())
|
||||||
|
.filter(|word| !word.is_empty())
|
||||||
|
.map(str::to_lowercase)
|
||||||
|
.collect();
|
||||||
|
Self { site, words }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same title anywhere (syndication), or mostly the same title on the
|
||||||
|
/// same site (a re-slugged republish).
|
||||||
|
fn same_as(&self, other: &Story) -> bool {
|
||||||
|
if self.words.is_empty() || other.words.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.words == other.words {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let shared = self.words.intersection(&other.words).count() as f64;
|
||||||
|
let union = self.words.union(&other.words).count() as f64;
|
||||||
|
!self.site.is_empty()
|
||||||
|
&& self.site == other.site
|
||||||
|
&& shared / union >= SAME_SITE_TITLE_OVERLAP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::curate::embedding::encode_blob;
|
||||||
|
|
||||||
|
fn voyage() -> VoyageConfig {
|
||||||
|
VoyageConfig {
|
||||||
|
model: "voyage-4-lite".into(),
|
||||||
|
output_dimension: 2,
|
||||||
|
..VoyageConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn harness() -> (tempfile::TempDir, Db) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db = Db::open_and_migrate(&dir.path().join("related.db"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(dir, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn article(db: &Db, id: ArticleId, title: &str, vector: &[f32], excerpt_only: bool) {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO articles (id, canonical_url, title, excerpt_only, first_seen)
|
||||||
|
VALUES (?, ?, ?, ?, '2026-08-15T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(format!("https://example.com/{id}"))
|
||||||
|
.bind(title)
|
||||||
|
.bind(excerpt_only)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let norm = vector.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||||
|
let unit: Vec<f32> = vector.iter().map(|v| v / norm).collect();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO article_embeddings
|
||||||
|
(article_id, model, dimension, input_hash, embedding, created_at)
|
||||||
|
VALUES (?, 'voyage-4-lite', 2, 'h', ?, '2026-08-15T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(encode_blob(&unit).unwrap())
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn issue(db: &Db, date: &str, picks: &[ArticleId]) {
|
||||||
|
sqlx::query("INSERT INTO issues (date, issue_number, generated_at) VALUES (?, 1, ?)")
|
||||||
|
.bind(date)
|
||||||
|
.bind("2026-09-01T00:00:00Z")
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for (position, id) in picks.iter().enumerate() {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO issue_articles (issue_date, article_id, section, position)
|
||||||
|
VALUES (?, ?, 'Top Stories', ?)",
|
||||||
|
)
|
||||||
|
.bind(date)
|
||||||
|
.bind(id)
|
||||||
|
.bind(position as i64)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn day(raw: &str) -> Date {
|
||||||
|
raw.parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn links_skip_the_issue_excerpts_duplicates_and_far_articles() {
|
||||||
|
let (_dir, db) = harness().await;
|
||||||
|
article(&db, 1, "Rust borrow checker", &[1.0, 0.0], false).await;
|
||||||
|
article(&db, 2, "Same issue neighbour", &[1.0, 0.01], false).await;
|
||||||
|
article(&db, 3, "Lifetimes explained", &[1.0, 0.1], false).await;
|
||||||
|
article(&db, 4, "Paywalled", &[1.0, 0.05], true).await;
|
||||||
|
article(&db, 5, "Rust Borrow-Checker!", &[1.0, 0.02], false).await;
|
||||||
|
article(&db, 6, "Ownership deep dive", &[1.0, 0.3], false).await;
|
||||||
|
article(&db, 7, "Ownership: deep dive", &[1.0, 0.31], false).await;
|
||||||
|
article(&db, 8, "Gardening", &[0.0, 1.0], false).await;
|
||||||
|
issue(&db, "2026-09-01", &[1, 2]).await;
|
||||||
|
|
||||||
|
let refreshed = refresh(&db, &voyage(), &[day("2026-09-01")]).await.unwrap();
|
||||||
|
let one = for_article(&db, day("2026-09-01"), 1).await.unwrap();
|
||||||
|
assert_eq!(one, vec![3, 6], "closest first, one copy of each story");
|
||||||
|
// 5 only repeats article 1's title, so it is fair game for article 2.
|
||||||
|
let two = for_article(&db, day("2026-09-01"), 2).await.unwrap();
|
||||||
|
assert_eq!(two, vec![5, 3, 6]);
|
||||||
|
assert_eq!(
|
||||||
|
refreshed,
|
||||||
|
Refreshed {
|
||||||
|
issues: 1,
|
||||||
|
links: 5
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refreshing_every_issue_replaces_rows_and_links_earlier_picks() {
|
||||||
|
let (_dir, db) = harness().await;
|
||||||
|
article(&db, 1, "One", &[1.0, 0.0], false).await;
|
||||||
|
article(&db, 2, "Two", &[1.0, 0.1], false).await;
|
||||||
|
article(&db, 3, "Three", &[1.0, 0.3], false).await;
|
||||||
|
issue(&db, "2026-09-01", &[1]).await;
|
||||||
|
issue(&db, "2026-09-02", &[2]).await;
|
||||||
|
|
||||||
|
assert_eq!(refresh(&db, &voyage(), &[]).await.unwrap().issues, 2);
|
||||||
|
// A rerun rewrites rather than duplicates.
|
||||||
|
refresh(&db, &voyage(), &[]).await.unwrap();
|
||||||
|
let ids = for_article(&db, day("2026-09-02"), 2).await.unwrap();
|
||||||
|
assert_eq!(ids, vec![1, 3]);
|
||||||
|
let issues = latest_issues(&db, &[1, 3]).await.unwrap();
|
||||||
|
assert_eq!(issues.get(&1), Some(&day("2026-09-01")));
|
||||||
|
assert_eq!(issues.get(&3), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stories_match_on_identical_titles_or_close_titles_from_one_site() {
|
||||||
|
let story = |site: &str, title: &str| Story {
|
||||||
|
site: site.into(),
|
||||||
|
words: title
|
||||||
|
.split(|c: char| !c.is_alphanumeric())
|
||||||
|
.filter(|word| !word.is_empty())
|
||||||
|
.map(str::to_lowercase)
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
let accusations = story(
|
||||||
|
"the-independent.com",
|
||||||
|
"The bestselling novel pulled from prestigious literary prize after AI accusations",
|
||||||
|
);
|
||||||
|
let allegations = story(
|
||||||
|
"the-independent.com",
|
||||||
|
"The bestselling novel pulled from prestigious literary prize after AI allegations",
|
||||||
|
);
|
||||||
|
let conversation = story(
|
||||||
|
"theconversation.com",
|
||||||
|
"A bestselling novel was pulled from a French literary prize after AI accusations – what next?",
|
||||||
|
);
|
||||||
|
assert!(accusations.same_as(&allegations), "a re-slugged republish");
|
||||||
|
assert!(
|
||||||
|
!accusations.same_as(&conversation),
|
||||||
|
"other outlets' coverage stays"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
story("a.com", "Foo: Bar").same_as(&story("b.com", "foo — bar")),
|
||||||
|
"syndicated copies match across sites"
|
||||||
|
);
|
||||||
|
assert!(!story("a.com", "").same_as(&story("a.com", "")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -353,7 +353,7 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[
|
|||||||
("curation.ranking.affinity_full", "Interest-attributable ratings at which affinity reaches full weight. Must be > affinity_floor."),
|
("curation.ranking.affinity_full", "Interest-attributable ratings at which affinity reaches full weight. Must be > affinity_floor."),
|
||||||
("curation.ranking.semantic_min_words", "Bodies shorter than this are not embedded."),
|
("curation.ranking.semantic_min_words", "Bodies shorter than this are not embedded."),
|
||||||
("curation.ranking.exploration_slots", "Shortlist slots reserved for exploration picks."),
|
("curation.ranking.exploration_slots", "Shortlist slots reserved for exploration picks."),
|
||||||
("curation.ranking.embedding_retention_days", "features prune: unrated, unpublished vectors older than this are deleted."),
|
("curation.ranking.embedding_retention_days", "features prune: unrated, unpublished vectors older than this are deleted; 0 keeps them forever."),
|
||||||
("curation.ranking.telemetry_retention_days", "features prune: candidate_runs and article_assessments older than this are deleted."),
|
("curation.ranking.telemetry_retention_days", "features prune: candidate_runs and article_assessments older than this are deleted."),
|
||||||
("curation.ranking.quotas.triage", "Deep-set slots filled by triage score."),
|
("curation.ranking.quotas.triage", "Deep-set slots filled by triage score."),
|
||||||
("curation.ranking.quotas.interest", "Deep-set slots filled by interest similarity."),
|
("curation.ranking.quotas.interest", "Deep-set slots filled by interest similarity."),
|
||||||
|
|||||||
+237
-20
@@ -943,23 +943,74 @@ struct ArticleLink {
|
|||||||
struct ArticleTemplate {
|
struct ArticleTemplate {
|
||||||
page: Page,
|
page: Page,
|
||||||
toc: Toc,
|
toc: Toc,
|
||||||
|
head: ArticleHead,
|
||||||
|
discussion_html: Option<String>,
|
||||||
|
rating: Option<RatingWidget>,
|
||||||
|
previous: Option<ArticleLink>,
|
||||||
|
next: Option<ArticleLink>,
|
||||||
|
issue_href: String,
|
||||||
|
related: Vec<RelatedView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An archive article that never ran in an issue, reached from a related list:
|
||||||
|
/// the stored body with no summary, rating or issue navigation.
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "article_standalone.html")]
|
||||||
|
struct StandaloneArticleTemplate {
|
||||||
|
page: Page,
|
||||||
|
head: ArticleHead,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `_article_head.html` renders (header and body) plus the footer links
|
||||||
|
/// both article pages share.
|
||||||
|
struct ArticleHead {
|
||||||
title: String,
|
title: String,
|
||||||
source_url: String,
|
source_url: String,
|
||||||
byline: Option<String>,
|
byline: Option<String>,
|
||||||
source: Source,
|
source: Source,
|
||||||
meta_line: String,
|
meta_line: String,
|
||||||
social_line: Option<String>,
|
social_line: Option<String>,
|
||||||
|
/// Issue-only: empty and `None` on the standalone page.
|
||||||
understanding: chapters::Understanding,
|
understanding: chapters::Understanding,
|
||||||
summary: Option<String>,
|
summary: Option<String>,
|
||||||
excerpt_only: bool,
|
excerpt_only: bool,
|
||||||
body_html: String,
|
body_html: String,
|
||||||
discussion_html: Option<String>,
|
|
||||||
read_online_url: String,
|
read_online_url: String,
|
||||||
dashboard_href: String,
|
dashboard_href: String,
|
||||||
rating: Option<RatingWidget>,
|
}
|
||||||
previous: Option<ArticleLink>,
|
|
||||||
next: Option<ArticleLink>,
|
impl ArticleHead {
|
||||||
issue_href: String,
|
fn new(article: &Article, config: &Config, is_admin: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
title: article.title.clone(),
|
||||||
|
source_url: article.canonical_url.clone(),
|
||||||
|
byline: article.author.as_ref().map(|author| format!("By {author}")),
|
||||||
|
source: Source::new(article, config, is_admin),
|
||||||
|
meta_line: format!(
|
||||||
|
"{} words · ~{} min read",
|
||||||
|
thousands(article.word_count),
|
||||||
|
article.reading_minutes()
|
||||||
|
),
|
||||||
|
social_line: chapters::social_line(&article.social),
|
||||||
|
understanding: chapters::Understanding::default(),
|
||||||
|
summary: None,
|
||||||
|
excerpt_only: article.excerpt_only,
|
||||||
|
body_html: prepare_body(&article.content_html),
|
||||||
|
read_online_url: article.url.clone(),
|
||||||
|
dashboard_href: format!("/dashboard/articles/{}", article.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry of the "Related" list under an issue article.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct RelatedView {
|
||||||
|
title: String,
|
||||||
|
href: String,
|
||||||
|
author: Option<String>,
|
||||||
|
source: Source,
|
||||||
|
reading_minutes: i64,
|
||||||
|
date: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
@@ -1114,31 +1165,21 @@ pub async fn article(
|
|||||||
let is_admin = viewer.role == crate::web::users::Role::Admin;
|
let is_admin = viewer.role == crate::web::users::Role::Admin;
|
||||||
let config = state.config();
|
let config = state.config();
|
||||||
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
||||||
|
let related = related_views(&state.db, &config, is_admin, date, article_id).await?;
|
||||||
let mut page = Page::new(article.title.clone(), Some(viewer.clone()), active_nav);
|
let mut page = Page::new(article.title.clone(), Some(viewer.clone()), active_nav);
|
||||||
page.flash = take_flash(&session).await?;
|
page.flash = take_flash(&session).await?;
|
||||||
Ok(Html(ArticleTemplate {
|
Ok(Html(ArticleTemplate {
|
||||||
page,
|
page,
|
||||||
toc,
|
toc,
|
||||||
title: article.title.clone(),
|
head: ArticleHead {
|
||||||
source_url: article.canonical_url.clone(),
|
|
||||||
byline: article.author.as_ref().map(|author| format!("By {author}")),
|
|
||||||
source: Source::new(article, &config, is_admin),
|
|
||||||
meta_line: format!(
|
|
||||||
"{} words · ~{} min read",
|
|
||||||
thousands(article.word_count),
|
|
||||||
article.reading_minutes()
|
|
||||||
),
|
|
||||||
social_line: chapters::social_line(&article.social),
|
|
||||||
understanding: chapters::understanding(pick),
|
understanding: chapters::understanding(pick),
|
||||||
summary: summary_for(&view.issue, pick).map(str::to_string),
|
summary: summary_for(&view.issue, pick).map(str::to_string),
|
||||||
excerpt_only: article.excerpt_only,
|
..ArticleHead::new(article, &config, is_admin)
|
||||||
body_html: prepare_body(&article.content_html),
|
},
|
||||||
discussion_html: pick
|
discussion_html: pick
|
||||||
.discussion
|
.discussion
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|discussion| crate::comments::render_xhtml(discussion, &article.title)),
|
.map(|discussion| crate::comments::render_xhtml(discussion, &article.title)),
|
||||||
read_online_url: article.url.clone(),
|
|
||||||
dashboard_href: format!("/dashboard/articles/{}", article.id),
|
|
||||||
rating: is_admin.then(|| {
|
rating: is_admin.then(|| {
|
||||||
RatingWidget::for_issue(
|
RatingWidget::for_issue(
|
||||||
article.id,
|
article.id,
|
||||||
@@ -1150,10 +1191,90 @@ pub async fn article(
|
|||||||
previous,
|
previous,
|
||||||
next,
|
next,
|
||||||
issue_href: issue_href(date),
|
issue_href: issue_href(date),
|
||||||
|
related,
|
||||||
})
|
})
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `/articles/{id}`: any stored article on its own page. One that ran in an
|
||||||
|
/// issue redirects to its (latest) issue page, which carries the summary and
|
||||||
|
/// related list.
|
||||||
|
pub async fn standalone(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
Path(article_id): Path<ArticleId>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let viewer = auth
|
||||||
|
.user()
|
||||||
|
.await
|
||||||
|
.map(Viewer::from)
|
||||||
|
.ok_or_else(|| WebError::Unauthenticated {
|
||||||
|
next: standalone_href(article_id),
|
||||||
|
})?;
|
||||||
|
let Some(article) = state.db.get_article(article_id).await? else {
|
||||||
|
return Err(WebError::NotFound);
|
||||||
|
};
|
||||||
|
if let Some(date) = crate::related::latest_issues(&state.db, &[article_id])
|
||||||
|
.await?
|
||||||
|
.remove(&article_id)
|
||||||
|
{
|
||||||
|
return Ok(Redirect::to(&article_href(date, article_id)).into_response());
|
||||||
|
}
|
||||||
|
let is_admin = viewer.role == crate::web::users::Role::Admin;
|
||||||
|
let config = state.config();
|
||||||
|
let mut page = Page::new(article.title.clone(), Some(viewer), "archive");
|
||||||
|
page.flash = take_flash(&session).await?;
|
||||||
|
Ok(Html(StandaloneArticleTemplate {
|
||||||
|
page,
|
||||||
|
head: ArticleHead::new(&article, &config, is_admin),
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored related links of one pick, each pointing at the issue page it
|
||||||
|
/// ran on or, failing that, its standalone page.
|
||||||
|
async fn related_views(
|
||||||
|
db: &Db,
|
||||||
|
config: &Config,
|
||||||
|
is_admin: bool,
|
||||||
|
date: Date,
|
||||||
|
article_id: ArticleId,
|
||||||
|
) -> anyhow::Result<Vec<RelatedView>> {
|
||||||
|
let ids = crate::related::for_article(db, date, article_id).await?;
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let articles = db.get_articles(&ids).await?;
|
||||||
|
let issues = crate::related::latest_issues(db, &ids).await?;
|
||||||
|
Ok(ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| {
|
||||||
|
let article = articles.get(id)?;
|
||||||
|
let href = match issues.get(id) {
|
||||||
|
Some(date) => article_href(*date, article.id),
|
||||||
|
None => standalone_href(article.id),
|
||||||
|
};
|
||||||
|
Some(RelatedView {
|
||||||
|
title: article.title.clone(),
|
||||||
|
href,
|
||||||
|
author: article.author.clone(),
|
||||||
|
source: Source::new(article, config, is_admin),
|
||||||
|
reading_minutes: article.reading_minutes(),
|
||||||
|
date: short_date(article.published_at.unwrap_or(article.first_seen), config),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Aug 15, 2026" in the configured zone.
|
||||||
|
fn short_date(timestamp: jiff::Timestamp, config: &Config) -> String {
|
||||||
|
match config.tz() {
|
||||||
|
Ok(tz) => timestamp.to_zoned(tz).strftime("%b %-d, %Y").to_string(),
|
||||||
|
Err(_) => timestamp.strftime("%b %-d, %Y").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn world(
|
pub async fn world(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthSession,
|
auth: AuthSession,
|
||||||
@@ -1335,6 +1456,10 @@ fn article_href(date: Date, article_id: ArticleId) -> String {
|
|||||||
format!("/issues/{date}/articles/{article_id}")
|
format!("/issues/{date}/articles/{article_id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn standalone_href(article_id: ArticleId) -> String {
|
||||||
|
format!("/articles/{article_id}")
|
||||||
|
}
|
||||||
|
|
||||||
fn issue_href(date: Date) -> String {
|
fn issue_href(date: Date) -> String {
|
||||||
format!("/issues/{date}")
|
format!("/issues/{date}")
|
||||||
}
|
}
|
||||||
@@ -1415,7 +1540,7 @@ fn thousands(value: i64) -> String {
|
|||||||
/// Sanitize article HTML exactly as the EPUB does, then add browser-only image
|
/// Sanitize article HTML exactly as the EPUB does, then add browser-only image
|
||||||
/// loading/referrer attributes without converting the fragment to XHTML.
|
/// loading/referrer attributes without converting the fragment to XHTML.
|
||||||
fn prepare_body(html: &str) -> String {
|
fn prepare_body(html: &str) -> String {
|
||||||
let clean = ammonia::clean(html);
|
let clean = crate::highlight::clean_and_highlight(html);
|
||||||
let mut output = String::with_capacity(clean.len() + 64);
|
let mut output = String::with_capacity(clean.len() + 64);
|
||||||
let mut cursor = 0usize;
|
let mut cursor = 0usize;
|
||||||
while let Some(relative) = clean[cursor..].find('<') {
|
while let Some(relative) = clean[cursor..].find('<') {
|
||||||
@@ -2198,6 +2323,98 @@ mod tests {
|
|||||||
assert!(!reader.contains(&feed_href));
|
assert!(!reader.contains(&feed_href));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn related_links_reach_issue_pages_and_standalone_archive_articles() {
|
||||||
|
let (_dir, db, source) = seeded_issue(true).await;
|
||||||
|
crate::web::users::add(&db, "reader", "correct horse battery", false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let pick = &source.lineup.picks[0].article;
|
||||||
|
let sibling = &source.lineup.picks[1].article;
|
||||||
|
let mut archived = pick.clone();
|
||||||
|
archived.id = 0;
|
||||||
|
archived.canonical_url = "https://example.com/archived-story".into();
|
||||||
|
archived.title = "An archived story nobody picked".into();
|
||||||
|
archived.content_html = "<p>Archived body text.</p>".into();
|
||||||
|
let archived_id = db.upsert_article(&archived).await.unwrap();
|
||||||
|
for (position, related_id) in [archived_id, sibling.id].into_iter().enumerate() {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO issue_related (issue_date, article_id, position, related_id, cosine)
|
||||||
|
VALUES (?, ?, ?, ?, 0.8)",
|
||||||
|
)
|
||||||
|
.bind(source.meta.date.to_string())
|
||||||
|
.bind(pick.id)
|
||||||
|
.bind(position as i64)
|
||||||
|
.bind(related_id)
|
||||||
|
.execute(db.pool())
|
||||||
|
.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 get = |uri: String, cookie: Option<String>| {
|
||||||
|
let app = app.clone();
|
||||||
|
async move {
|
||||||
|
let mut request = Request::builder().uri(uri);
|
||||||
|
if let Some(cookie) = cookie {
|
||||||
|
request = request.header(header::COOKIE, cookie);
|
||||||
|
}
|
||||||
|
app.oneshot(request.body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = response_text(
|
||||||
|
get(
|
||||||
|
format!("/issues/{}/articles/{}", source.meta.date, pick.id),
|
||||||
|
Some(cookie.clone()),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let related = page
|
||||||
|
.split_once("id=\"related-heading\"")
|
||||||
|
.expect("related list")
|
||||||
|
.1;
|
||||||
|
let archived_at = related
|
||||||
|
.find(&format!("href=\"/articles/{archived_id}\""))
|
||||||
|
.expect("unpicked article links to its standalone page");
|
||||||
|
let sibling_at = related
|
||||||
|
.find(&format!(
|
||||||
|
"href=\"/issues/{}/articles/{}\"",
|
||||||
|
source.meta.date, sibling.id
|
||||||
|
))
|
||||||
|
.expect("a picked article links to its issue page");
|
||||||
|
assert!(archived_at < sibling_at, "stored order is kept");
|
||||||
|
|
||||||
|
let standalone = get(format!("/articles/{archived_id}"), Some(cookie.clone())).await;
|
||||||
|
assert_eq!(standalone.status(), StatusCode::OK);
|
||||||
|
let standalone = response_text(standalone).await;
|
||||||
|
assert!(standalone.contains("An archived story nobody picked"));
|
||||||
|
assert!(standalone.contains("Archived body text."));
|
||||||
|
|
||||||
|
let picked = get(format!("/articles/{}", pick.id), Some(cookie)).await;
|
||||||
|
assert_eq!(picked.status(), StatusCode::SEE_OTHER);
|
||||||
|
assert_eq!(
|
||||||
|
picked.headers()[header::LOCATION],
|
||||||
|
format!("/issues/{}/articles/{}", source.meta.date, pick.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
let anonymous = get(format!("/articles/{archived_id}"), None).await;
|
||||||
|
assert!(anonymous.status().is_redirection());
|
||||||
|
assert!(
|
||||||
|
anonymous.headers()[header::LOCATION]
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("/login")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fallback_full_issue_recovers_colophon_world_and_behind() {
|
async fn fallback_full_issue_recovers_colophon_world_and_behind() {
|
||||||
let (_dir, db, source) = seeded_issue(false).await;
|
let (_dir, db, source) = seeded_issue(false).await;
|
||||||
|
|||||||
@@ -681,6 +681,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
|
|||||||
.route_layer(from_fn(session::require_password_change));
|
.route_layer(from_fn(session::require_password_change));
|
||||||
let full_issues = axum::Router::new()
|
let full_issues = axum::Router::new()
|
||||||
.route("/issues/{date}/articles/{article_id}", get(issue::article))
|
.route("/issues/{date}/articles/{article_id}", get(issue::article))
|
||||||
|
.route("/articles/{article_id}", get(issue::standalone))
|
||||||
.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("/issues/{date}/read", get(issue::read))
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
||||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-serif:"Newsreader", "Newsreader Fallback Georgia", "Newsreader Fallback Times", "Newsreader Fallback Noto Serif", "Newsreader Fallback DejaVu Serif", "Iowan Old Style", "Palatino Linotype", Georgia, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-normal:0em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, monospace;--color-paper:var(--paper);--color-paper-2:var(--paper-2);--color-ink:var(--ink)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settLine truncated
|
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial}}}@layer theme{:root,:host{--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-normal:0em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, monospace}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{paddiLine truncated
|
||||||
+43
-5
@@ -1,4 +1,4 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss" source(none);
|
||||||
@source "../web/templates";
|
@source "../web/templates";
|
||||||
@source "./static/app.js";
|
@source "./static/app.js";
|
||||||
|
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
--paper:#f6f3ec; --paper-2:#ece7db; --ink:#1c1b18; --ink-2:#4f4b43; --muted:#6b665a;
|
--paper:#f6f3ec; --paper-2:#ece7db; --ink:#1c1b18; --ink-2:#4f4b43; --muted:#6b665a;
|
||||||
--rule:#d8d2c4; --rule-strong:#1c1b18; --accent:#a3231f; --accent-hover:#7c1a16;
|
--rule:#d8d2c4; --rule-strong:#1c1b18; --accent:#a3231f; --accent-hover:#7c1a16;
|
||||||
--loved:#2f6f46; --good:#2f6a8f; --down:#9c3f36; --warn:#7d631c; color-scheme:light;
|
--loved:#2f6f46; --good:#2f6a8f; --down:#9c3f36; --warn:#7d631c; color-scheme:light;
|
||||||
|
--hl-keyword:#8c2a22; --hl-string:#3b6246; --hl-number:#7a4f16; --hl-type:#2c5572;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -90,6 +91,7 @@
|
|||||||
--paper:#151513; --paper-2:#1e1d1a; --ink:#ebe6da; --ink-2:#bfb9aa; --muted:#8f8a7d;
|
--paper:#151513; --paper-2:#1e1d1a; --ink:#ebe6da; --ink-2:#bfb9aa; --muted:#8f8a7d;
|
||||||
--rule:#3a3833; --rule-strong:#ebe6da; --accent:#ef8c82; --accent-hover:#f7aaa2;
|
--rule:#3a3833; --rule-strong:#ebe6da; --accent:#ef8c82; --accent-hover:#f7aaa2;
|
||||||
--loved:#7fcb92; --good:#7fb8d8; --down:#ea8f84; --warn:#d9b45a; color-scheme:dark;
|
--loved:#7fcb92; --good:#7fb8d8; --down:#ea8f84; --warn:#d9b45a; color-scheme:dark;
|
||||||
|
--hl-keyword:#e3a197; --hl-string:#a3c9a9; --hl-number:#d8b77a; --hl-type:#9dbdd3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +99,7 @@
|
|||||||
--paper:#151513; --paper-2:#1e1d1a; --ink:#ebe6da; --ink-2:#bfb9aa; --muted:#8f8a7d;
|
--paper:#151513; --paper-2:#1e1d1a; --ink:#ebe6da; --ink-2:#bfb9aa; --muted:#8f8a7d;
|
||||||
--rule:#3a3833; --rule-strong:#ebe6da; --accent:#ef8c82; --accent-hover:#f7aaa2;
|
--rule:#3a3833; --rule-strong:#ebe6da; --accent:#ef8c82; --accent-hover:#f7aaa2;
|
||||||
--loved:#7fcb92; --good:#7fb8d8; --down:#ea8f84; --warn:#d9b45a; color-scheme:dark;
|
--loved:#7fcb92; --good:#7fb8d8; --down:#ea8f84; --warn:#d9b45a; color-scheme:dark;
|
||||||
|
--hl-keyword:#e3a197; --hl-string:#a3c9a9; --hl-number:#d8b77a; --hl-type:#9dbdd3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -159,8 +162,8 @@
|
|||||||
.kv { @apply grid grid-cols-[minmax(9rem,1fr)_2fr] gap-x-5 gap-y-1.5; }
|
.kv { @apply grid grid-cols-[minmax(9rem,1fr)_2fr] gap-x-5 gap-y-1.5; }
|
||||||
.kv dt { @apply text-sm text-muted; }
|
.kv dt { @apply text-sm text-muted; }
|
||||||
.kv dd { @apply m-0 min-w-0 text-sm; overflow-wrap:anywhere; }
|
.kv dd { @apply m-0 min-w-0 text-sm; overflow-wrap:anywhere; }
|
||||||
|
/* Dashboard table layout contract: docs/dashboard-tables.md. */
|
||||||
.scroll-x { @apply max-w-full overflow-x-auto; overscroll-behavior-inline:contain; }
|
.scroll-x { @apply max-w-full overflow-x-auto; overscroll-behavior-inline:contain; }
|
||||||
.scroll-x > table { min-width:max-content; }
|
|
||||||
.scroll-x.tall { @apply max-h-[70vh] overflow-y-auto; overscroll-behavior-block:contain; }
|
.scroll-x.tall { @apply max-h-[70vh] overflow-y-auto; overscroll-behavior-block:contain; }
|
||||||
|
|
||||||
.reader-page { @apply min-w-0; overflow-wrap:anywhere; }
|
.reader-page { @apply min-w-0; overflow-wrap:anywhere; }
|
||||||
@@ -178,6 +181,17 @@
|
|||||||
.prose-body th, .prose-body td { overflow-wrap:normal; }
|
.prose-body th, .prose-body td { overflow-wrap:normal; }
|
||||||
.prose-body pre { @apply max-w-full overflow-x-auto rounded-sm bg-paper-2 p-4 text-sm leading-relaxed; }
|
.prose-body pre { @apply max-w-full overflow-x-auto rounded-sm bg-paper-2 p-4 text-sm leading-relaxed; }
|
||||||
.prose-body :not(pre) > code { @apply rounded-sm bg-paper-2 px-1 py-0.5 text-[0.9em]; }
|
.prose-body :not(pre) > code { @apply rounded-sm bg-paper-2 px-1 py-0.5 text-[0.9em]; }
|
||||||
|
/* Syntax tokens (src/highlight.rs): ink-first, with a few print colours —
|
||||||
|
oxblood keywords, bottle-green strings, umber literals, slate types. All at
|
||||||
|
least 4.5:1 on paper-2 in both themes. Names and variables stay ink. */
|
||||||
|
.hl-keyword, .hl-tag, .hl-diff-delete { color:var(--hl-keyword); }
|
||||||
|
.hl-string, .hl-diff-add { color:var(--hl-string); }
|
||||||
|
.hl-number, .hl-constant, .hl-macro, .hl-attribute, .hl-label { color:var(--hl-number); }
|
||||||
|
.hl-type, .hl-namespace, .hl-constructor { color:var(--hl-type); }
|
||||||
|
.hl-comment { color:var(--muted); font-style:italic; }
|
||||||
|
.hl-punctuation, .hl-operator { color:var(--ink-2); }
|
||||||
|
.hl-title, .hl-strong { font-weight:600; }
|
||||||
|
.hl-emphasis { font-style:italic; }
|
||||||
.prose-body blockquote { @apply my-7 border-l-2 border-rule-strong pl-5 text-ink-2; }
|
.prose-body blockquote { @apply my-7 border-l-2 border-rule-strong pl-5 text-ink-2; }
|
||||||
.prose-body ul { @apply list-disc pl-6; }
|
.prose-body ul { @apply list-disc pl-6; }
|
||||||
.prose-body ol { @apply list-decimal pl-6; }
|
.prose-body ol { @apply list-decimal pl-6; }
|
||||||
@@ -219,7 +233,30 @@
|
|||||||
ul.error, ul.notice { @apply list-disc pl-8; }
|
ul.error, ul.notice { @apply list-disc pl-8; }
|
||||||
|
|
||||||
/* ---------- dashboard shell ---------- */
|
/* ---------- dashboard shell ---------- */
|
||||||
.dashboard { @apply mx-auto my-8 max-w-7xl px-4 pb-6 font-sans text-base leading-normal sm:px-6; }
|
.dashboard {
|
||||||
|
--gutter:1rem;
|
||||||
|
@apply my-8 grid pb-6 font-sans text-base leading-normal;
|
||||||
|
grid-template-columns:
|
||||||
|
[full-start] var(--gutter)
|
||||||
|
[wide-start] 1fr
|
||||||
|
[content-start] minmax(0, calc(80rem - 2 * var(--gutter)))
|
||||||
|
[content-end] 1fr
|
||||||
|
[wide-end] var(--gutter)
|
||||||
|
[full-end];
|
||||||
|
}
|
||||||
|
@media (width >= 40rem) { .dashboard { --gutter:1.5rem; } }
|
||||||
|
/* Grid items never collapse margins, so a child's bottom margin (`.table-filter`, `.rating`) would add to
|
||||||
|
the next child's `mt-4`; the rhythm comes from top margins only. `!important` beats those utilities. */
|
||||||
|
.dashboard > * { grid-column:content; min-width:0; margin-bottom:0 !important; }
|
||||||
|
/* The wrapper shrinks to its table (a centred grid item is sized fit-content), so the
|
||||||
|
flanks beside a narrow table stay page, not scroll container: wheeling there scrolls the page. */
|
||||||
|
.dashboard > .scroll-x {
|
||||||
|
grid-column:wide;
|
||||||
|
justify-self:center;
|
||||||
|
min-width:min(100%, calc(80rem - 2 * var(--gutter)));
|
||||||
|
max-width:100%;
|
||||||
|
}
|
||||||
|
.dashboard > .scroll-x > table { width:auto; min-width:100%; }
|
||||||
.dashboard > * + * { @apply mt-4; }
|
.dashboard > * + * { @apply mt-4; }
|
||||||
.dashboard h1 { @apply text-2xl font-semibold leading-tight tracking-[-0.01em]; }
|
.dashboard h1 { @apply text-2xl font-semibold leading-tight tracking-[-0.01em]; }
|
||||||
.dashboard h2 { @apply text-lg font-semibold; }
|
.dashboard h2 { @apply text-lg font-semibold; }
|
||||||
@@ -254,8 +291,9 @@
|
|||||||
|
|
||||||
.muted, .meta { @apply text-muted; }
|
.muted, .meta { @apply text-muted; }
|
||||||
.jobs a, .picks > li > a:first-child { @apply text-ink no-underline hover:text-accent; }
|
.jobs a, .picks > li > a:first-child { @apply text-ink no-underline hover:text-accent; }
|
||||||
.num { @apply whitespace-nowrap text-right tabular-nums; }
|
.num { @apply text-right tabular-nums; }
|
||||||
.cell-wrap { @apply min-w-[10rem] max-w-[24rem] whitespace-normal; overflow-wrap:anywhere; }
|
td.num { @apply whitespace-nowrap; }
|
||||||
|
.cell-wrap { @apply min-w-[10rem] max-w-[32rem] whitespace-normal; overflow-wrap:anywhere; }
|
||||||
.cell-tight { @apply whitespace-nowrap; }
|
.cell-tight { @apply whitespace-nowrap; }
|
||||||
|
|
||||||
.filters { @apply flex flex-wrap items-end gap-x-4 gap-y-3 border-y border-rule py-3 font-sans text-sm; }
|
.filters { @apply flex flex-wrap items-end gap-x-4 gap-y-3 border-y border-rule py-3 font-sans text-sm; }
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<header><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Article</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl"><a class="text-ink no-underline hover:text-accent" href="{{ head.source_url }}">{{ head.title }}</a></h1>{% match head.byline %}{% when Some with (byline) %}<p class="mt-5 font-sans text-sm font-medium text-ink-2">{{ byline }}</p>{% when None %}{% endmatch %}<p class="mt-1 font-sans text-sm text-muted">{% let source = head.source %}{% include "_source.html" %} · {{ head.meta_line }}</p>{% let understanding = head.understanding %}{% include "_understanding.html" %}{% match head.summary %}{% when Some with (summary) %}<p class="mt-7 text-xl italic leading-relaxed text-ink-2">{{ summary }}</p>{% when None %}{% endmatch %}{% match head.social_line %}{% when Some with (social) %}<p class="mt-4 font-sans text-sm text-muted">{{ social }}</p>{% when None %}{% endmatch %}{% if head.excerpt_only %}<p class="notice mt-6">Excerpt only — continue reading at the original site.</p>{% endif %}</header>
|
||||||
|
<div class="prose-body mt-10 border-t border-rule pt-8">{{ head.body_html|safe }}</div>
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="cell-wrap"><details class="signals"><summary><a href="{{ candidate.href }}">{{ candidate.title }}</a></summary>{% let signals = candidate.signals %}{% include "_signals_table.html" %}</details></td>
|
<td class="cell-wrap"><details class="signals"><summary><a href="{{ candidate.href }}">{{ candidate.title }}</a></summary>{% let signals = candidate.signals %}{% include "_signals_table.html" %}</details></td>
|
||||||
<td class="cell-tight text-muted">{{ candidate.feed }}</td>
|
<td class="text-muted">{{ candidate.feed }}</td>
|
||||||
<td class="num">{{ candidate.words }}</td>
|
<td class="num">{{ candidate.words }}</td>
|
||||||
<td class="cell-tight"><span class="badge {{ candidate.stage }}">{{ candidate.stage }}</span></td>
|
<td><span class="badge {{ candidate.stage }}">{{ candidate.stage }}</span></td>
|
||||||
<td class="cell-tight">{% if let Some(reason) = candidate.reason %}<span class="badge reason">{{ reason }}</span>{% endif %}</td>
|
<td>{% if let Some(reason) = candidate.reason %}<span class="badge reason">{{ reason }}</span>{% endif %}</td>
|
||||||
<td class="cell-tight text-muted">{% if let Some(first) = candidate.admitted_first %}{{ first }}{% if !candidate.admitted_rest.is_empty() %} <span class="muted text-xs">({{ candidate.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
<td class="text-muted">{% if let Some(first) = candidate.admitted_first %}{{ first }}{% if !candidate.admitted_rest.is_empty() %} <span class="muted text-xs">({{ candidate.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
||||||
<td class="num">{{ candidate.utility }}</td>
|
<td class="num">{{ candidate.utility }}</td>
|
||||||
<td class="num">{{ candidate.rank }}</td>
|
<td class="num">{{ candidate.rank }}</td>
|
||||||
<td class="num">{{ candidate.cluster }}</td>
|
<td class="num">{{ candidate.cluster }}</td>
|
||||||
<td class="num">{{ candidate.triage }}</td>
|
<td class="num">{{ candidate.triage }}</td>
|
||||||
<td class="num">{{ candidate.quality }}</td>
|
<td class="num">{{ candidate.quality }}</td>
|
||||||
<td class="num">{{ candidate.fit }}</td>
|
<td class="num">{{ candidate.fit }}</td>
|
||||||
<td class="cell-tight">{% if candidate.exploration %}<span class="badge">exploration</span>{% endif %}{% if candidate.auto_include %}<span class="badge">auto</span>{% endif %}</td>
|
<td>{% if candidate.exploration %}<span class="badge">exploration</span>{% endif %}{% if candidate.auto_include %}<span class="badge">auto</span>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% if !related.is_empty() %}<section class="mt-12" aria-labelledby="related-heading"><h2 id="related-heading" class="reader-section-heading">Related</h2><ul class="m-0 list-none p-0">{% for item in related %}<li class="border-b border-rule py-5"><h3 class="font-serif text-xl font-semibold leading-[1.15] tracking-[-0.01em]"><a class="text-ink no-underline hover:text-accent" href="{{ item.href }}">{{ item.title }}</a></h3><p class="mt-1.5 font-sans text-sm text-muted">{% match item.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{% let source = item.source %}{% include "_source.html" %} · {{ item.reading_minutes }} min read · {{ item.date }}</p></li>{% endfor %}</ul></section>{% endif %}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:gap-x-8 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
|
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:gap-x-8 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
|
||||||
<article class="reader-page mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0" data-toc-scroll>
|
<article class="reader-page mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0" data-toc-scroll>
|
||||||
<header><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Article</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl"><a class="text-ink no-underline hover:text-accent" href="{{ source_url }}">{{ title }}</a></h1>{% match byline %}{% when Some with (byline) %}<p class="mt-5 font-sans text-sm font-medium text-ink-2">{{ byline }}</p>{% when None %}{% endmatch %}<p class="mt-1 font-sans text-sm text-muted">{% include "_source.html" %} · {{ meta_line }}</p>{% include "_understanding.html" %}{% match summary %}{% when Some with (summary) %}<p class="mt-7 text-xl italic leading-relaxed text-ink-2">{{ summary }}</p>{% when None %}{% endmatch %}{% match social_line %}{% when Some with (social) %}<p class="mt-4 font-sans text-sm text-muted">{{ social }}</p>{% when None %}{% endmatch %}{% if excerpt_only %}<p class="notice mt-6">Excerpt only — continue reading at the original site.</p>{% endif %}</header>
|
{% include "_article_head.html" %}
|
||||||
<div class="prose-body mt-10 border-t border-rule pt-8">{{ body_html|safe }}</div>
|
|
||||||
{% match discussion_html %}{% when Some with (discussion) %}<section class="discussion prose-body mt-12 border-t border-rule pt-6 [&_.comment-meta]:font-sans [&_.comment-meta]:text-sm [&_.comment-meta]:text-muted [&_blockquote.reply]:ml-6"><h2 class="mb-6 border-0 pt-0 text-2xl">Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %}
|
{% match discussion_html %}{% when Some with (discussion) %}<section class="discussion prose-body mt-12 border-t border-rule pt-6 [&_.comment-meta]:font-sans [&_.comment-meta]:text-sm [&_.comment-meta]:text-muted [&_blockquote.reply]:ml-6"><h2 class="mb-6 border-0 pt-0 text-2xl">Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %}
|
||||||
<footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6 flex flex-wrap gap-2"><a class="btn" href="{{ read_online_url }}">Read online ↗</a>{% if page.is_admin() %}<a class="btn" href="{{ dashboard_href }}">Dashboard</a>{% endif %}</p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer>
|
{% include "_related.html" %}
|
||||||
|
<footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6 flex flex-wrap gap-2"><a class="btn" href="{{ head.read_online_url }}">Read online ↗</a>{% if page.is_admin() %}<a class="btn" href="{{ head.dashboard_href }}">Dashboard</a>{% endif %}</p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer>
|
||||||
</article>
|
</article>
|
||||||
</div>{% endblock %}
|
</div>{% endblock %}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{% extends "layout.html" %}{% block ears %}Article{% endblock %}{% block content %}
|
||||||
|
<article class="reader-page mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0">
|
||||||
|
{% include "_article_head.html" %}
|
||||||
|
<footer class="mt-12 border-t border-rule pt-6"><p class="flex flex-wrap gap-2"><a class="btn" href="{{ head.read_online_url }}">Read online ↗</a>{% if page.is_admin() %}<a class="btn" href="{{ head.dashboard_href }}">Dashboard</a>{% endif %}</p></footer>
|
||||||
|
</article>{% endblock %}
|
||||||
@@ -47,9 +47,9 @@
|
|||||||
<tbody>{% for row in history %}<tr>
|
<tbody>{% for row in history %}<tr>
|
||||||
<td class="cell-wrap"><details class="signals"><summary><a href="{{ row.run_href }}">run {{ row.run_id }}</a> <span class="badge {{ row.status }}">{{ row.status }}</span></summary>{% let signals = row.signals %}{% include "_signals_table.html" %}</details></td>
|
<td class="cell-wrap"><details class="signals"><summary><a href="{{ row.run_href }}">run {{ row.run_id }}</a> <span class="badge {{ row.status }}">{{ row.status }}</span></summary>{% let signals = row.signals %}{% include "_signals_table.html" %}</details></td>
|
||||||
<td class="cell-tight text-muted">{{ row.date }}</td>
|
<td class="cell-tight text-muted">{{ row.date }}</td>
|
||||||
<td class="cell-tight"><span class="badge {{ row.stage }}">{{ row.stage }}</span></td>
|
<td><span class="badge {{ row.stage }}">{{ row.stage }}</span></td>
|
||||||
<td class="cell-tight text-muted">{% if let Some(reason) = row.reason %}{{ reason }}{% endif %}</td>
|
<td class="text-muted">{% if let Some(reason) = row.reason %}{{ reason }}{% endif %}</td>
|
||||||
<td class="cell-tight text-muted">{% if let Some(first) = row.admitted_first %}{{ first }}{% if !row.admitted_rest.is_empty() %} <span class="muted text-xs">({{ row.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
<td class="text-muted">{% if let Some(first) = row.admitted_first %}{{ first }}{% if !row.admitted_rest.is_empty() %} <span class="muted text-xs">({{ row.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
||||||
<td class="num">{{ row.utility }}</td>
|
<td class="num">{{ row.utility }}</td>
|
||||||
<td class="num">{{ row.rank }}</td>
|
<td class="num">{{ row.rank }}</td>
|
||||||
<td class="num">{{ row.cluster }}</td>
|
<td class="num">{{ row.cluster }}</td>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
<h2>Neighbours and interests</h2>
|
<h2>Neighbours and interests</h2>
|
||||||
{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}<p class="muted text-sm">The latest run recorded no interests or neighbours for this article.</p>{% else %}<div class="cards">
|
{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}<p class="muted text-sm">The latest run recorded no interests or neighbours for this article.</p>{% else %}<div class="cards">
|
||||||
<section class="card"><h3>Top interests</h3>{% if signals.top_interests.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>interest</th><th class="num">z</th><th class="num">cos</th></tr></thead><tbody>{% for interest in signals.top_interests %}<tr><td><a href="{{ interest.href }}">{{ interest.name }}</a></td><td class="num">{{ interest.z }}</td><td class="num">{{ interest.cos }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
<section class="card"><h3>Top interests</h3>{% if signals.top_interests.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>interest</th><th class="num">z</th><th class="num">cos</th></tr></thead><tbody>{% for interest in signals.top_interests %}<tr><td class="cell-wrap"><a href="{{ interest.href }}">{{ interest.name }}</a></td><td class="num">{{ interest.z }}</td><td class="num">{{ interest.cos }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
||||||
<section class="card"><h3>Nearest rated neighbours</h3>{% if signals.neighbours.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>label</th><th class="num">cos</th><th>article</th></tr></thead><tbody>{% for neighbour in signals.neighbours %}<tr><td><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span></td><td class="num">{{ neighbour.cos }}</td><td class="cell-wrap"><a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a></td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
<section class="card"><h3>Nearest rated neighbours</h3>{% if signals.neighbours.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>label</th><th class="num">cos</th><th>article</th></tr></thead><tbody>{% for neighbour in signals.neighbours %}<tr><td><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span></td><td class="num">{{ neighbour.cos }}</td><td class="cell-wrap"><a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a></td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
||||||
</div>{% endif %}{% else %}<p class="muted text-sm">No signals recorded.</p>{% endif %}
|
</div>{% endif %}{% else %}<p class="muted text-sm">No signals recorded.</p>{% endif %}
|
||||||
|
|
||||||
@@ -67,9 +67,9 @@
|
|||||||
<tbody>{% for article in articles %}<tr>
|
<tbody>{% for article in articles %}<tr>
|
||||||
<td class="num">{{ article.cosine }}</td>
|
<td class="num">{{ article.cosine }}</td>
|
||||||
<td class="cell-wrap"><a href="/dashboard/articles/{{ article.id }}">{{ article.title }}</a></td>
|
<td class="cell-wrap"><a href="/dashboard/articles/{{ article.id }}">{{ article.title }}</a></td>
|
||||||
<td class="cell-tight">{{ article.feed }}</td>
|
<td>{{ article.feed }}</td>
|
||||||
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
|
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
|
||||||
<td class="cell-tight">{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% else %}<span class="muted">unrated</span>{% endif %}</td>
|
<td>{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% else %}<span class="muted">unrated</span>{% endif %}</td>
|
||||||
</tr>{% endfor %}</tbody></table></div>{% endif %}{% else %}<p class="muted text-sm">No embedding stored — run features-backfill.</p>{% endif %}
|
</tr>{% endfor %}</tbody></table></div>{% endif %}{% else %}<p class="muted text-sm">No embedding stored — run features-backfill.</p>{% endif %}
|
||||||
|
|
||||||
<h2>Embedding</h2>
|
<h2>Embedding</h2>
|
||||||
@@ -80,12 +80,12 @@
|
|||||||
<thead><tr><th>when</th><th>label</th><th class="num">value</th><th>kind</th><th>source</th><th>user</th><th>issue</th><th>note</th></tr></thead>
|
<thead><tr><th>when</th><th>label</th><th class="num">value</th><th>kind</th><th>source</th><th>user</th><th>issue</th><th>note</th></tr></thead>
|
||||||
<tbody>{% for event in events %}<tr>
|
<tbody>{% for event in events %}<tr>
|
||||||
<td class="cell-tight text-muted">{{ event.event_at }}</td>
|
<td class="cell-tight text-muted">{{ event.event_at }}</td>
|
||||||
<td class="cell-tight"><span class="badge {{ event.label_class }}">{{ event.label }}</span></td>
|
<td><span class="badge {{ event.label_class }}">{{ event.label }}</span></td>
|
||||||
<td class="num">{{ event.value }}</td>
|
<td class="num">{{ event.value }}</td>
|
||||||
<td class="cell-tight text-muted">{{ event.kind }}</td>
|
<td class="text-muted">{{ event.kind }}</td>
|
||||||
<td class="cell-tight text-muted">{{ event.source }}</td>
|
<td class="text-muted">{{ event.source }}</td>
|
||||||
<td class="cell-tight">{% if let Some(user) = event.user %}{{ user }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
<td>{% if let Some(user) = event.user %}{{ user }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||||
<td class="cell-tight">{% if let Some(date) = event.issue_date %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
<td class="cell-tight">{% if let Some(date) = event.issue_date %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
||||||
<td class="cell-wrap text-muted">{% if let Some(note) = event.note %}{{ note }}{% endif %}</td>
|
<td class="cell-wrap text-muted">{% if let Some(note) = event.note %}<div class="line-clamp-3" title="{{ note }}">{{ note }}</div>{% endif %}</td>
|
||||||
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
</section>{% endblock %}
|
</section>{% endblock %}
|
||||||
@@ -18,21 +18,21 @@
|
|||||||
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/articles">Reset</a></div>
|
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/articles">Reset</a></div>
|
||||||
</form>
|
</form>
|
||||||
{% include "dashboard/_pager.html" %}
|
{% include "dashboard/_pager.html" %}
|
||||||
{% if articles.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table class="articles" data-filter>
|
{% if articles.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table class="articles" data-filter>
|
||||||
<thead><tr><th>first seen</th><th>title</th><th>feed</th><th>interests</th><th class="num">words</th><th>last stage</th><th>reason</th><th class="num">utility</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>rating</th><th>published</th></tr></thead>
|
<thead><tr><th>first seen</th><th>title</th><th>feed</th><th>interests</th><th class="num">words</th><th>last stage</th><th>reason</th><th class="num">utility</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>rating</th><th>published</th></tr></thead>
|
||||||
<tbody>{% for article in articles %}<tr>
|
<tbody>{% for article in articles %}<tr>
|
||||||
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
|
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
|
||||||
<td class="cell-wrap"><a href="{{ article.href }}">{{ article.title }}</a></td>
|
<td class="cell-wrap"><a href="{{ article.href }}">{{ article.title }}</a></td>
|
||||||
<td class="cell-tight">{% if let Some(feed_id) = article.feed_id %}<a href="/dashboard/articles?feed={{ feed_id }}">{{ article.feed }}</a>{% else %}{{ article.feed }}{% endif %}</td>
|
<td>{% if let Some(feed_id) = article.feed_id %}<a href="/dashboard/articles?feed={{ feed_id }}">{{ article.feed }}</a>{% else %}{{ article.feed }}{% endif %}</td>
|
||||||
<td class="cell-wrap">{% for interest in article.interests %}<a class="badge" href="{{ interest.href }}">{{ interest.name }}</a>{% if !loop.last %} {% endif %}{% endfor %}</td>
|
<td>{% for interest in article.interests %}<a class="badge" href="{{ interest.href }}">{{ interest.name }}</a>{% if !loop.last %} {% endif %}{% endfor %}</td>
|
||||||
<td class="num">{{ article.words }}</td>
|
<td class="num">{{ article.words }}</td>
|
||||||
<td class="cell-tight">{% if let Some(stage) = article.stage %}<span class="badge {{ stage }}">{{ stage }}</span>{% if let Some(run_id) = article.run_id %} <a class="muted text-xs" href="/dashboard/runs/{{ run_id }}">{% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}</a>{% endif %}{% else %}<span class="muted text-xs">never considered</span>{% endif %}</td>
|
<td>{% if let Some(stage) = article.stage %}<span class="badge {{ stage }}">{{ stage }}</span>{% if let Some(run_id) = article.run_id %} <a class="muted text-xs" href="/dashboard/runs/{{ run_id }}">{% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}</a>{% endif %}{% else %}<span class="muted text-xs">never considered</span>{% endif %}</td>
|
||||||
<td class="cell-tight text-muted">{% if let Some(reason) = article.reason %}{{ reason }}{% endif %}</td>
|
<td class="text-muted">{% if let Some(reason) = article.reason %}{{ reason }}{% endif %}</td>
|
||||||
<td class="num">{{ article.utility }}</td>
|
<td class="num">{{ article.utility }}</td>
|
||||||
<td class="num">{{ article.triage }}</td>
|
<td class="num">{{ article.triage }}</td>
|
||||||
<td class="num">{{ article.quality }}</td>
|
<td class="num">{{ article.quality }}</td>
|
||||||
<td class="num">{{ article.fit }}</td>
|
<td class="num">{{ article.fit }}</td>
|
||||||
<td class="cell-tight">{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% endif %}</td>
|
<td>{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% endif %}</td>
|
||||||
<td class="cell-tight">{% if let Some(date) = article.published %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
<td class="cell-tight">{% if let Some(date) = article.published %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
||||||
</tr>{% endfor %}{% if articles.is_empty() %}<tr><td colspan="13" class="text-muted">No articles match this filter.</td></tr>{% endif %}</tbody></table></div>
|
</tr>{% endfor %}{% if articles.is_empty() %}<tr><td colspan="13" class="text-muted">No articles match this filter.</td></tr>{% endif %}</tbody></table></div>
|
||||||
{% include "dashboard/_pager.html" %}
|
{% include "dashboard/_pager.html" %}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<nav class="tabs" aria-label="Candidate status"><a href="/dashboard/feeds"{% if status == "candidate" %} class="active" aria-current="page"{% endif %}>Candidates ({{ candidate_count }})</a> <a href="/dashboard/feeds?status=added"{% if status == "added" %} class="active" aria-current="page"{% endif %}>Added ({{ added_count }})</a> <a href="/dashboard/feeds?status=dismissed"{% if status == "dismissed" %} class="active" aria-current="page"{% endif %}>Dismissed ({{ dismissed_count }})</a></nav>
|
<nav class="tabs" aria-label="Candidate status"><a href="/dashboard/feeds"{% if status == "candidate" %} class="active" aria-current="page"{% endif %}>Candidates ({{ candidate_count }})</a> <a href="/dashboard/feeds?status=added"{% if status == "added" %} class="active" aria-current="page"{% endif %}>Added ({{ added_count }})</a> <a href="/dashboard/feeds?status=dismissed"{% if status == "dismissed" %} class="active" aria-current="page"{% endif %}>Dismissed ({{ dismissed_count }})</a></nav>
|
||||||
{% if let Some(error) = categories_error %}<p class="notice">{{ error }}</p>{% endif %}
|
{% if let Some(error) = categories_error %}<p class="notice">{{ error }}</p>{% endif %}
|
||||||
{% include "dashboard/_pager.html" %}
|
{% include "dashboard/_pager.html" %}
|
||||||
<div class="scroll-x"><table class="table-fixed">
|
<div class="scroll-x"><table>
|
||||||
<thead><tr><th class="num w-14">Score</th><th>Feed</th><th>Why</th><th class="num w-20">Articles</th><th class="w-20">Seen</th><th class="w-60 lg:w-84"><span class="sr-only">Actions</span></th></tr></thead>
|
<thead><tr><th class="num w-14">Score</th><th>Feed</th><th>Why</th><th class="num w-20">Articles</th><th class="w-20">Seen</th><th class="w-60 lg:w-84"><span class="sr-only">Actions</span></th></tr></thead>
|
||||||
<tbody>{% for row in rows %}<tr>
|
<tbody>{% for row in rows %}<tr>
|
||||||
<td class="num tabular-nums">{{ row.score }}</td>
|
<td class="num tabular-nums">{{ row.score }}</td>
|
||||||
|
|||||||
@@ -16,11 +16,11 @@
|
|||||||
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/interests">Reset</a></div>
|
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/interests">Reset</a></div>
|
||||||
</form>
|
</form>
|
||||||
{% if rows.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter interests" aria-label="Filter interests" data-table-filter>{% endif %}
|
{% if rows.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter interests" aria-label="Filter interests" data-table-filter>{% endif %}
|
||||||
<div class="scroll-x tall"><table data-filter>
|
<div class="scroll-x"><table data-filter>
|
||||||
<thead><tr><th>interest</th><th>category</th><th class="num">weight</th><th class="num">up</th><th class="num">down</th><th class="num">rated matches</th><th class="num">matched articles</th><th>added</th><th>actions</th></tr></thead>
|
<thead><tr><th>interest</th><th>category</th><th class="num">weight</th><th class="num">up</th><th class="num">down</th><th class="num">rated matches</th><th class="num">matched articles</th><th>added</th><th>actions</th></tr></thead>
|
||||||
<tbody>{% for row in rows %}<tr>
|
<tbody>{% for row in rows %}<tr>
|
||||||
<td class="cell-wrap"><a href="{{ row.href }}">{{ row.name }}</a></td>
|
<td class="cell-wrap"><a href="{{ row.href }}">{{ row.name }}</a></td>
|
||||||
<td class="cell-tight">{% if let Some(category) = row.category %}<a href="{{ row.category_href }}">{{ category }}</a>{% else %}<a class="muted" href="{{ row.category_href }}">uncategorized</a>{% endif %}</td>
|
<td>{% if let Some(category) = row.category %}<a href="{{ row.category_href }}">{{ category }}</a>{% else %}<a class="muted" href="{{ row.category_href }}">uncategorized</a>{% endif %}</td>
|
||||||
<td class="num tabular-nums">{{ row.weight }}</td>
|
<td class="num tabular-nums">{{ row.weight }}</td>
|
||||||
<td class="num tabular-nums">{{ row.up }}</td>
|
<td class="num tabular-nums">{{ row.up }}</td>
|
||||||
<td class="num tabular-nums">{{ row.down }}</td>
|
<td class="num tabular-nums">{{ row.down }}</td>
|
||||||
|
|||||||
@@ -17,18 +17,18 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>{% endfor %}</div>
|
</section>{% endfor %}</div>
|
||||||
<h2>History</h2>
|
<h2>History</h2>
|
||||||
{% if jobs.is_empty() %}<p class="muted text-sm">No jobs recorded yet.</p>{% else %}{% if jobs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
|
{% if jobs.is_empty() %}<p class="muted text-sm">No jobs recorded yet.</p>{% else %}{% if jobs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table data-filter>
|
||||||
<thead><tr><th class="num">job</th><th>name</th><th>requested by</th><th>requested</th><th>started</th><th>finished</th><th class="num">duration</th><th>status</th><th>message</th><th class="num">run</th></tr></thead>
|
<thead><tr><th class="num">job</th><th>name</th><th>requested by</th><th>requested</th><th>started</th><th>finished</th><th class="num">duration</th><th>status</th><th>message</th><th class="num">run</th></tr></thead>
|
||||||
<tbody>{% for job in jobs %}<tr>
|
<tbody>{% for job in jobs %}<tr>
|
||||||
<td class="num"><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
|
<td class="num"><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
|
||||||
<td class="cell-tight font-mono text-xs">{{ job.name }}</td>
|
<td class="font-mono text-xs">{{ job.name }}</td>
|
||||||
<td class="cell-tight text-muted">{{ job.requested_by }}</td>
|
<td class="text-muted">{{ job.requested_by }}</td>
|
||||||
<td class="cell-tight text-muted">{{ job.requested }}</td>
|
<td class="cell-tight text-muted">{{ job.requested }}</td>
|
||||||
<td class="cell-tight text-muted">{{ job.started }}</td>
|
<td class="cell-tight text-muted">{{ job.started }}</td>
|
||||||
<td class="cell-tight text-muted">{{ job.finished }}</td>
|
<td class="cell-tight text-muted">{{ job.finished }}</td>
|
||||||
<td class="num">{{ job.duration }}</td>
|
<td class="num">{{ job.duration }}</td>
|
||||||
<td class="cell-tight"><span class="badge {{ job.status }}">{{ job.status }}</span></td>
|
<td><span class="badge {{ job.status }}">{{ job.status }}</span></td>
|
||||||
<td class="message cell-wrap text-muted">{% if let Some(message) = job.message %}{{ message }}{% endif %}</td>
|
<td class="message cell-wrap text-muted">{% if let Some(message) = job.message %}<div class="line-clamp-3" title="{{ message }}">{{ message }}</div>{% endif %}</td>
|
||||||
<td class="num">{% if let Some(run_id) = job.run_id %}<a href="/dashboard/runs/{{ run_id }}">{{ run_id }}</a>{% endif %}</td>
|
<td class="num">{% if let Some(run_id) = job.run_id %}<a href="/dashboard/runs/{{ run_id }}">{{ run_id }}</a>{% endif %}</td>
|
||||||
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
</section>{% endblock %}
|
</section>{% endblock %}
|
||||||
@@ -30,10 +30,10 @@
|
|||||||
<tbody>{% for version in versions %}<tr>
|
<tbody>{% for version in versions %}<tr>
|
||||||
<td class="num">#{{ version.id }}</td>
|
<td class="num">#{{ version.id }}</td>
|
||||||
<td class="cell-tight text-muted">{{ version.saved_at }}</td>
|
<td class="cell-tight text-muted">{{ version.saved_at }}</td>
|
||||||
<td class="cell-tight">{{ version.saved_by }}</td>
|
<td>{{ version.saved_by }}</td>
|
||||||
<td class="num text-muted">{{ version.bytes }} bytes</td>
|
<td class="num text-muted">{{ version.bytes }} bytes</td>
|
||||||
<td><pre class="preview">{{ version.preview }}</pre></td>
|
<td class="cell-wrap"><pre class="preview">{{ version.preview }}</pre></td>
|
||||||
<td class="cell-tight"><form method="post" action="/dashboard/profile/restore" data-confirm="Restore version #{{ version.id }}? The current file is kept as a new version."><input type="hidden" name="version_id" value="{{ version.id }}"><button class="btn" type="submit">Restore</button></form></td>
|
<td><form method="post" action="/dashboard/profile/restore" data-confirm="Restore version #{{ version.id }}? The current file is kept as a new version."><input type="hidden" name="version_id" value="{{ version.id }}"><button class="btn" type="submit">Restore</button></form></td>
|
||||||
</tr>{% endfor %}</tbody></table></div>
|
</tr>{% endfor %}</tbody></table></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
<tbody>{% for row in imports %}<tr>
|
<tbody>{% for row in imports %}<tr>
|
||||||
<td class="cell-tight text-muted">{{ row.requested }}</td>
|
<td class="cell-tight text-muted">{{ row.requested }}</td>
|
||||||
<td class="cell-wrap"><a href="{{ row.url }}" rel="noopener" target="_blank">{{ row.url }}</a></td>
|
<td class="cell-wrap"><a href="{{ row.url }}" rel="noopener" target="_blank">{{ row.url }}</a></td>
|
||||||
<td class="cell-tight"><span class="badge {{ row.label }}">{{ row.verdict }}</span></td>
|
<td><span class="badge {{ row.label }}">{{ row.verdict }}</span></td>
|
||||||
<td class="cell-tight"><span class="badge {{ row.status }}">{{ row.status }}</span></td>
|
<td><span class="badge {{ row.status }}">{{ row.status }}</span></td>
|
||||||
<td class="cell-wrap text-muted">{{ row.message }}</td>
|
<td class="cell-wrap text-muted"><div class="line-clamp-3" title="{{ row.message }}">{{ row.message }}</div></td>
|
||||||
<td class="num">{% if let Some(article_id) = row.article_id %}<a href="/dashboard/articles/{{ article_id }}">{{ article_id }}</a>{% endif %}</td>
|
<td class="num">{% if let Some(article_id) = row.article_id %}<a href="/dashboard/articles/{{ article_id }}">{{ article_id }}</a>{% endif %}</td>
|
||||||
</tr>{% endfor %}
|
</tr>{% endfor %}
|
||||||
{% if imports.is_empty() %}<tr><td colspan="6" class="text-muted">No rating imports yet.</td></tr>{% endif %}</tbody>
|
{% if imports.is_empty() %}<tr><td colspan="6" class="text-muted">No rating imports yet.</td></tr>{% endif %}</tbody>
|
||||||
@@ -55,19 +55,19 @@
|
|||||||
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ events_href }}">Reset</a></div>
|
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ events_href }}">Reset</a></div>
|
||||||
</form>
|
</form>
|
||||||
<p class="meta text-sm">{{ pagination.total }} events, newest first. History is append-only; a later explicit event for the same article marks the earlier one <em>superseded</em>. Use the clear verdict to retract.</p>
|
<p class="meta text-sm">{{ pagination.total }} events, newest first. History is append-only; a later explicit event for the same article marks the earlier one <em>superseded</em>. Use the clear verdict to retract.</p>
|
||||||
<div class="scroll-x tall"><table>
|
<div class="scroll-x"><table>
|
||||||
<thead><tr><th class="num">Event</th><th>Verdict</th><th class="num">Value</th><th>Article</th><th>Issue</th><th>When</th><th>Source</th><th>By</th><th>Note</th></tr></thead>
|
<thead><tr><th class="num">Event</th><th>Verdict</th><th class="num">Value</th><th>Article</th><th>Issue</th><th>When</th><th>Source</th><th>By</th><th>Note</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for event in events %}<tr{% if event.superseded %} class="superseded"{% endif %}>
|
{% for event in events %}<tr{% if event.superseded %} class="superseded"{% endif %}>
|
||||||
<td class="num">#{{ event.id }}{% if event.kind != "explicit" %} <span class="badge">{{ event.kind }}</span>{% endif %}{% if event.superseded %} <span class="badge cleared">superseded</span>{% endif %}</td>
|
<td class="num">#{{ event.id }}{% if event.kind != "explicit" %} <span class="badge">{{ event.kind }}</span>{% endif %}{% if event.superseded %} <span class="badge cleared">superseded</span>{% endif %}</td>
|
||||||
<td class="cell-tight"><span class="badge {{ event.badge }}">{{ event.verdict }}</span></td>
|
<td><span class="badge {{ event.badge }}">{{ event.verdict }}</span></td>
|
||||||
<td class="num">{{ event.value }}</td>
|
<td class="num">{{ event.value }}</td>
|
||||||
<td class="cell-wrap"><a href="/dashboard/articles/{{ event.article_id }}">{{ event.title }}</a></td>
|
<td class="cell-wrap"><a href="/dashboard/articles/{{ event.article_id }}">{{ event.title }}</a></td>
|
||||||
<td class="cell-tight">{% if !event.issue_date.is_empty() %}<a href="/issues/{{ event.issue_date }}">{{ event.issue_date }}</a>{% endif %}</td>
|
<td class="cell-tight">{% if !event.issue_date.is_empty() %}<a href="/issues/{{ event.issue_date }}">{{ event.issue_date }}</a>{% endif %}</td>
|
||||||
<td class="cell-tight text-muted">{{ event.when }}</td>
|
<td class="cell-tight text-muted">{{ event.when }}</td>
|
||||||
<td class="cell-tight text-muted">{{ event.source }}</td>
|
<td class="text-muted">{{ event.source }}</td>
|
||||||
<td class="cell-tight text-muted">{{ event.username }}</td>
|
<td class="text-muted">{{ event.username }}</td>
|
||||||
<td class="cell-wrap text-muted">{{ event.note }}</td>
|
<td class="cell-wrap text-muted"><div class="line-clamp-3" title="{{ event.note }}">{{ event.note }}</div></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if events.is_empty() %}<tr><td colspan="9" class="text-muted">No rating events match.</td></tr>{% endif %}
|
{% if events.is_empty() %}<tr><td colspan="9" class="text-muted">No rating events match.</td></tr>{% endif %}
|
||||||
@@ -83,20 +83,20 @@
|
|||||||
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ current_href }}">Reset</a></div>
|
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ current_href }}">Reset</a></div>
|
||||||
</form>
|
</form>
|
||||||
<p class="meta text-sm">One row per rated article (its latest explicit event), newest first. {% match last_run %}{% when Some with (run) %}"Used last run" counts the candidates of run #{{ run.id }} ({{ run.date }}, {{ run.status }}) that listed the article among their nearest rated neighbours, and how many of those were selected.{% when None %}No run has happened yet, so "Used last run" is empty.{% endmatch %}</p>
|
<p class="meta text-sm">One row per rated article (its latest explicit event), newest first. {% match last_run %}{% when Some with (run) %}"Used last run" counts the candidates of run #{{ run.id }} ({{ run.date }}, {{ run.status }}) that listed the article among their nearest rated neighbours, and how many of those were selected.{% when None %}No run has happened yet, so "Used last run" is empty.{% endmatch %}</p>
|
||||||
<div class="scroll-x tall"><table class="contributions">
|
<div class="scroll-x"><table class="contributions">
|
||||||
<thead><tr><th>Verdict</th><th>Article</th><th>When · by</th><th>Note</th><th>Age → decay</th><th class="num">Neighbour weight</th><th>Feed credit</th><th>In prompt</th><th>In rebuild</th><th>Used last run</th></tr></thead>
|
<thead><tr><th>Verdict</th><th>Article</th><th>When · by</th><th>Note</th><th>Age → decay</th><th class="num">Neighbour weight</th><th>Feed credit</th><th>In prompt</th><th>In rebuild</th><th>Used last run</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in current %}<tr>
|
{% for row in current %}<tr>
|
||||||
<td class="rating-cell min-w-[13rem]">{% let widget = row.widget %}{% include "_rating_widget.html" %}</td>
|
<td class="rating-cell min-w-[13rem]">{% let widget = row.widget %}{% include "_rating_widget.html" %}</td>
|
||||||
<td class="cell-wrap"><a href="/dashboard/articles/{{ row.article_id }}">{{ row.title }}</a><br><span class="meta text-xs">{{ row.feed_title }}{% if !row.issue_date.is_empty() %} · <a href="/issues/{{ row.issue_date }}">{{ row.issue_date }}</a>{% endif %}</span></td>
|
<td class="cell-wrap"><a href="/dashboard/articles/{{ row.article_id }}">{{ row.title }}</a><br><span class="meta text-xs">{{ row.feed_title }}{% if !row.issue_date.is_empty() %} · <a href="/issues/{{ row.issue_date }}">{{ row.issue_date }}</a>{% endif %}</span></td>
|
||||||
<td class="cell-tight">{{ row.when }}<br><span class="meta text-xs">{{ row.source }} · <span class="by">{{ row.username }}</span></span></td>
|
<td class="cell-tight">{{ row.when }}<br><span class="meta text-xs">{{ row.source }} · <span class="by">{{ row.username }}</span></span></td>
|
||||||
<td class="cell-wrap text-muted">{{ row.note }}</td>
|
<td class="cell-wrap text-muted"><div class="line-clamp-3" title="{{ row.note }}">{{ row.note }}</div></td>
|
||||||
<td class="cell-tight tabular-nums">{{ row.age_days }} d → {{ row.decay }}</td>
|
<td class="cell-tight tabular-nums">{{ row.age_days }} d → {{ row.decay }}</td>
|
||||||
<td class="num">{% if row.has_embedding && !row.neighbour_weight.is_empty() %}{{ row.neighbour_weight }}{% if row.beyond_lookback %} <span class="meta text-xs">(beyond lookback)</span>{% endif %}{% else if row.badge == "cleared" %}<span class="meta">—</span>{% else %}<span class="meta text-xs">no embedding</span>{% endif %}</td>
|
<td class="num">{% if row.has_embedding && !row.neighbour_weight.is_empty() %}{{ row.neighbour_weight }}{% if row.beyond_lookback %} <span class="meta text-xs">(beyond lookback)</span>{% endif %}{% else if row.badge == "cleared" %}<span class="meta">—</span>{% else %}<span class="meta text-xs">no embedding</span>{% endif %}</td>
|
||||||
<td class="cell-tight text-muted">{% for credit in row.feed_credits %}{{ credit.title }} <span class="tabular-nums text-ink">{{ credit.credit }}</span>{% if !loop.last %}<br>{% endif %}{% endfor %}</td>
|
<td class="cell-wrap text-muted">{% for credit in row.feed_credits %}{{ credit.title }} <span class="tabular-nums text-ink">{{ credit.credit }}</span>{% if !loop.last %}<br>{% endif %}{% endfor %}</td>
|
||||||
<td class="text-center">{% if row.in_prompt %}<span class="text-loved">✓</span>{% endif %}</td>
|
<td class="text-center">{% if row.in_prompt %}<span class="text-loved">✓</span>{% endif %}</td>
|
||||||
<td class="text-center">{% if row.in_rebuild %}<span class="text-loved">✓</span>{% endif %}</td>
|
<td class="text-center">{% if row.in_rebuild %}<span class="text-loved">✓</span>{% endif %}</td>
|
||||||
<td class="cell-tight tabular-nums">{% if row.used_total > 0 %}{{ row.used_total }} ({{ row.used_selected }} selected){% endif %}</td>
|
<td class="tabular-nums">{% if row.used_total > 0 %}{{ row.used_total }} ({{ row.used_selected }} selected){% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if current.is_empty() %}<tr><td colspan="10" class="text-muted">No current verdicts match.</td></tr>{% endif %}
|
{% if current.is_empty() %}<tr><td colspan="10" class="text-muted">No current verdicts match.</td></tr>{% endif %}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class="scroll-x"><table class="funnel-table">
|
<div class="scroll-x"><table class="funnel-table">
|
||||||
<thead><tr><th>stage</th><th>share</th><th class="num">reached</th><th class="num">stopped here</th><th>why</th></tr></thead>
|
<thead><tr><th>stage</th><th>share</th><th class="num">reached</th><th class="num">stopped here</th><th>why</th></tr></thead>
|
||||||
<tbody>{% for stage in funnel %}<tr>
|
<tbody>{% for stage in funnel %}<tr>
|
||||||
<td class="cell-tight align-middle"><span class="badge {{ stage.stage }}">{{ stage.stage }}</span></td>
|
<td class="align-middle"><span class="badge {{ stage.stage }}">{{ stage.stage }}</span></td>
|
||||||
<td class="funnel-cell"><div class="funnel"><svg class="h-3" width="{{ stage.percent }}%" role="img" aria-label="{{ stage.reached }} reached"></svg></div></td>
|
<td class="funnel-cell"><div class="funnel"><svg class="h-3" width="{{ stage.percent }}%" role="img" aria-label="{{ stage.reached }} reached"></svg></div></td>
|
||||||
<td class="num align-middle">{{ stage.reached }}</td>
|
<td class="num align-middle">{{ stage.reached }}</td>
|
||||||
<td class="num align-middle text-muted">{{ stage.stopped }}</td>
|
<td class="num align-middle text-muted">{{ stage.stopped }}</td>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
<h2>Near misses</h2>
|
<h2>Near misses</h2>
|
||||||
{% if near_misses.is_empty() %}<p class="muted text-sm">None recorded.</p>{% else %}<div class="scroll-x"><table>
|
{% if near_misses.is_empty() %}<p class="muted text-sm">None recorded.</p>{% else %}<div class="scroll-x"><table>
|
||||||
<thead><tr><th class="num">#</th><th class="num">score</th><th>title</th><th>feed</th><th class="num">quality</th><th class="num">fit</th><th>stage</th><th>reason</th></tr></thead>
|
<thead><tr><th class="num">#</th><th class="num">score</th><th>title</th><th>feed</th><th class="num">quality</th><th class="num">fit</th><th>stage</th><th>reason</th></tr></thead>
|
||||||
<tbody>{% for miss in near_misses %}<tr><td class="num text-muted">{{ loop.index }}</td><td class="num">{{ miss.score }}</td><td class="cell-wrap"><a href="{{ miss.href }}">{{ miss.title }}</a></td><td class="cell-tight text-muted">{{ miss.feed }}</td><td class="num">{{ miss.quality }}</td><td class="num">{{ miss.fit }}</td><td class="cell-tight"><span class="badge {{ miss.stage }}">{{ miss.stage }}</span></td><td class="cell-wrap text-muted">{% if let Some(reason) = miss.reason %}{{ reason }}{% endif %}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
<tbody>{% for miss in near_misses %}<tr><td class="num text-muted">{{ loop.index }}</td><td class="num">{{ miss.score }}</td><td class="cell-wrap"><a href="{{ miss.href }}">{{ miss.title }}</a></td><td class="text-muted">{{ miss.feed }}</td><td class="num">{{ miss.quality }}</td><td class="num">{{ miss.fit }}</td><td><span class="badge {{ miss.stage }}">{{ miss.stage }}</span></td><td class="cell-wrap text-muted">{% if let Some(reason) = miss.reason %}{{ reason }}{% endif %}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
|
||||||
<h2>Candidates</h2>
|
<h2>Candidates</h2>
|
||||||
<form class="filters" method="get" action="/dashboard/runs/{{ run.id }}">
|
<form class="filters" method="get" action="/dashboard/runs/{{ run.id }}">
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/runs/{{ run.id }}">Reset</a></div>
|
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/runs/{{ run.id }}">Reset</a></div>
|
||||||
</form>
|
</form>
|
||||||
{% include "dashboard/_pager.html" %}
|
{% include "dashboard/_pager.html" %}
|
||||||
{% if candidates.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table class="candidates" data-filter>
|
{% if candidates.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table class="candidates" data-filter>
|
||||||
<thead><tr><th>title</th><th>feed</th><th class="num">words</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>flags</th></tr></thead>
|
<thead><tr><th>title</th><th>feed</th><th class="num">words</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>flags</th></tr></thead>
|
||||||
<tbody>{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}{% if candidates.is_empty() %}<tr><td colspan="13" class="text-muted">No candidates match this filter.</td></tr>{% endif %}</tbody></table></div>
|
<tbody>{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}{% if candidates.is_empty() %}<tr><td colspan="13" class="text-muted">No candidates match this filter.</td></tr>{% endif %}</tbody></table></div>
|
||||||
{% include "dashboard/_pager.html" %}
|
{% include "dashboard/_pager.html" %}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
<label>Status <select name="status"><option value="">any</option>{% for name in statuses %}<option value="{{ name }}"{% if status == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
<label>Status <select name="status"><option value="">any</option>{% for name in statuses %}<option value="{{ name }}"{% if status == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
<div class="filter-actions"><button class="btn" type="submit">Filter</button>{% if !status.is_empty() %}<a class="btn-quiet" href="/dashboard/runs">Reset</a>{% endif %}</div>
|
<div class="filter-actions"><button class="btn" type="submit">Filter</button>{% if !status.is_empty() %}<a class="btn-quiet" href="/dashboard/runs">Reset</a>{% endif %}</div>
|
||||||
</form>
|
</form>
|
||||||
{% if runs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
|
{% if runs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table data-filter>
|
||||||
<thead><tr><th>run</th><th>date</th><th>status</th><th>started</th><th>duration</th><th>considered → eligible → triaged → assessed → shortlisted → selected</th><th>cost</th><th class="num">total</th><th class="num">warnings</th></tr></thead>
|
<thead><tr><th>run</th><th>date</th><th>status</th><th>started</th><th>duration</th><th>considered → eligible → triaged → assessed → shortlisted → selected</th><th>cost</th><th class="num">total</th><th class="num">warnings</th></tr></thead>
|
||||||
<tbody>{% for run in runs %}<tr>
|
<tbody>{% for run in runs %}<tr>
|
||||||
<td class="num"><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
|
<td class="num"><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
|
||||||
<td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td>
|
<td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td>
|
||||||
<td class="cell-tight"><span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="muted text-xs">dry</span>{% endif %}</td>
|
<td><span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="muted text-xs">dry</span>{% endif %}</td>
|
||||||
<td class="cell-tight text-muted">{{ run.started }}</td>
|
<td class="cell-tight text-muted">{{ run.started }}</td>
|
||||||
<td class="num text-muted">{{ run.duration }}</td>
|
<td class="num text-muted">{{ run.duration }}</td>
|
||||||
<td class="cell-tight tabular-nums">{{ run.funnel }}</td>
|
<td class="cell-tight tabular-nums">{{ run.funnel }}</td>
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
<h1>Settings history</h1>
|
<h1>Settings history</h1>
|
||||||
<p class="page-desc">Every settings change made through the dashboard, newest first. Hand edits to <code>config.toml</code> on disk are not recorded here.</p>
|
<p class="page-desc">Every settings change made through the dashboard, newest first. Hand edits to <code>config.toml</code> on disk are not recorded here.</p>
|
||||||
</div><div class="page-actions"><a class="btn" href="/dashboard/settings">Back to settings</a></div></header>
|
</div><div class="page-actions"><a class="btn" href="/dashboard/settings">Back to settings</a></div></header>
|
||||||
{% if changes.is_empty() %}<p class="muted text-sm">No settings have been changed through the dashboard yet.</p>{% else %}{% if changes.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table class="history" data-filter>
|
{% if changes.is_empty() %}<p class="muted text-sm">No settings have been changed through the dashboard yet.</p>{% else %}{% if changes.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table class="history" data-filter>
|
||||||
<thead><tr><th>When</th><th>Who</th><th>Key</th><th>Before</th><th>After</th></tr></thead>
|
<thead><tr><th>When</th><th>Who</th><th>Key</th><th>Before</th><th>After</th></tr></thead>
|
||||||
<tbody>{% for change in changes %}<tr>
|
<tbody>{% for change in changes %}<tr>
|
||||||
<td class="cell-tight text-muted">{{ change.changed_at }}</td>
|
<td class="cell-tight text-muted">{{ change.changed_at }}</td>
|
||||||
<td class="cell-tight">{% match change.username %}{% when Some with (name) %}{{ name }}{% when None %}<em class="text-muted">removed user</em>{% endmatch %}</td>
|
<td>{% match change.username %}{% when Some with (name) %}{{ name }}{% when None %}<em class="text-muted">removed user</em>{% endmatch %}</td>
|
||||||
<td><code>{{ change.key }}</code></td>
|
<td><code>{{ change.key }}</code></td>
|
||||||
<td class="cell-wrap">{% match change.old_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">absent</em>{% endmatch %}</td>
|
<td class="cell-wrap">{% match change.old_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">absent</em>{% endmatch %}</td>
|
||||||
<td class="cell-wrap">{% match change.new_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">removed</em>{% endmatch %}</td>
|
<td class="cell-wrap">{% match change.new_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">removed</em>{% endmatch %}</td>
|
||||||
|
|||||||
@@ -27,14 +27,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Spend by day</h2>
|
<h2>Spend by day</h2>
|
||||||
{% if cost_rows.is_empty() %}<p class="muted text-sm">No provider costs recorded in the window.</p>{% else %}{% if cost_rows.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
|
{% if cost_rows.is_empty() %}<p class="muted text-sm">No provider costs recorded in the window.</p>{% else %}{% if cost_rows.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table data-filter>
|
||||||
<thead><tr><th>day (UTC)</th>{% for provider in providers %}<th class="num">{{ provider }}</th>{% endfor %}<th class="num">total</th></tr></thead>
|
<thead><tr><th>day (UTC)</th>{% for provider in providers %}<th class="num">{{ provider }}</th>{% endfor %}<th class="num">total</th></tr></thead>
|
||||||
<tbody>{% for row in cost_rows %}<tr><td class="cell-tight">{{ row.date }}</td>{% for cell in row.cells %}<td class="num text-muted">{{ cell }}</td>{% endfor %}<td class="num">{{ row.total }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
<tbody>{% for row in cost_rows %}<tr><td class="cell-tight">{{ row.date }}</td>{% for cell in row.cells %}<td class="num text-muted">{{ cell }}</td>{% endfor %}<td class="num">{{ row.total }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
|
||||||
<h2>Runs in the window</h2>
|
<h2>Runs in the window</h2>
|
||||||
{% if runs.is_empty() %}<p class="muted text-sm">No finished runs in the window.</p>{% else %}{% if runs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
|
{% if runs.is_empty() %}<p class="muted text-sm">No finished runs in the window.</p>{% else %}{% if runs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table data-filter>
|
||||||
<thead><tr><th class="num">run</th><th>date</th><th>status</th><th class="num">cost</th><th class="num">selected</th><th class="num">duration</th></tr></thead>
|
<thead><tr><th class="num">run</th><th>date</th><th>status</th><th class="num">cost</th><th class="num">selected</th><th class="num">duration</th></tr></thead>
|
||||||
<tbody>{% for run in runs %}<tr><td class="num"><a href="/dashboard/runs/{{ run.run_id }}">{{ run.run_id }}</a></td><td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td><td class="cell-tight"><span class="badge {{ run.status }}">{{ run.status }}</span></td><td class="num">{{ run.cost }}</td><td class="num">{{ run.selected }}</td><td class="num text-muted">{{ run.duration }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
<tbody>{% for run in runs %}<tr><td class="num"><a href="/dashboard/runs/{{ run.run_id }}">{{ run.run_id }}</a></td><td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td><td><span class="badge {{ run.status }}">{{ run.status }}</span></td><td class="num">{{ run.cost }}</td><td class="num">{{ run.selected }}</td><td class="num text-muted">{{ run.duration }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
|
||||||
<details class="explain disclosure" id="stats-text"><summary>As text (<code>daily-epub stats --days {{ days }}</code>)</summary><div class="disclosure-body"><pre class="preview">{{ text }}</pre></div></details>
|
<details class="explain disclosure" id="stats-text"><summary>As text (<code>daily-epub stats --days {{ days }}</code>)</summary><div class="disclosure-body"><pre class="preview">{{ text }}</pre></div></details>
|
||||||
</section>{% endblock %}
|
</section>{% endblock %}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<td><div class="flex flex-wrap gap-2"><form class="form-inline" method="post" action="/dashboard/users/requests/{{ request.id }}/approve"><input type="text" name="username" value="{{ request.suggested_username }}" autocomplete="username" maxlength="32" required pattern="[A-Za-z0-9._-]+" aria-label="Username for {{ request.email }}"><button class="btn-primary" type="submit"{% if !mail_configured %} disabled title="Email is not configured"{% endif %}>Approve</button></form><form method="post" action="/dashboard/users/requests/{{ request.id }}/done"><button class="btn" type="submit">Mark done</button></form></div></td>
|
<td><div class="flex flex-wrap gap-2"><form class="form-inline" method="post" action="/dashboard/users/requests/{{ request.id }}/approve"><input type="text" name="username" value="{{ request.suggested_username }}" autocomplete="username" maxlength="32" required pattern="[A-Za-z0-9._-]+" aria-label="Username for {{ request.email }}"><button class="btn-primary" type="submit"{% if !mail_configured %} disabled title="Email is not configured"{% endif %}>Approve</button></form><form method="post" action="/dashboard/users/requests/{{ request.id }}/done"><button class="btn" type="submit">Mark done</button></form></div></td>
|
||||||
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% if users.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
|
{% if users.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table data-filter>
|
||||||
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th>Last login</th><th class="num">Open sessions</th></tr></thead>
|
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th>Last login</th><th class="num">Open sessions</th></tr></thead>
|
||||||
<tbody>{% for user in users %}<tr>
|
<tbody>{% for user in users %}<tr>
|
||||||
<td class="font-medium text-ink">{{ user.username }}</td>
|
<td class="font-medium text-ink">{{ user.username }}</td>
|
||||||
|
|||||||
Reference in new issue
Block a user